Repository: fantasytyx/bv Branch: develop Commit: b84935bf29c9 Files: 945 Total size: 4.8 MB Directory structure: gitextract_fcqp1f9p/ ├── .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 ├── CHANGELOG.md ├── 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 │ │ │ │ │ ├── activities/ │ │ │ │ │ │ └── LauncherActivity.kt │ │ │ │ │ ├── component/ │ │ │ │ │ │ ├── BvPlayerPreview.kt │ │ │ │ │ │ ├── DevelopingTip.kt │ │ │ │ │ │ ├── FpsMonitor.kt │ │ │ │ │ │ ├── QrImage.kt │ │ │ │ │ │ └── settings/ │ │ │ │ │ │ └── UpdateDialog.kt │ │ │ │ │ ├── dao/ │ │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ │ ├── SearchHistoryDao.kt │ │ │ │ │ │ └── UserDao.kt │ │ │ │ │ ├── entity/ │ │ │ │ │ │ ├── AuthData.kt │ │ │ │ │ │ ├── BvScheme.kt │ │ │ │ │ │ ├── InterfaceMode.kt │ │ │ │ │ │ ├── NavSwitchMode.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 │ │ │ │ │ ├── player/ │ │ │ │ │ │ └── entity/ │ │ │ │ │ │ ├── DefaultSubtitle.kt │ │ │ │ │ │ ├── NextVideoStrategy.kt │ │ │ │ │ │ ├── PlayerDefaultStartPosition.kt │ │ │ │ │ │ └── PlayerLoadNextAction.kt │ │ │ │ │ ├── repository/ │ │ │ │ │ │ ├── UserRepository.kt │ │ │ │ │ │ └── VideoInfoRepository.kt │ │ │ │ │ ├── ui/ │ │ │ │ │ │ └── theme/ │ │ │ │ │ │ ├── Theme.kt │ │ │ │ │ │ └── Typography.kt │ │ │ │ │ ├── util/ │ │ │ │ │ │ ├── AbiUtil.kt │ │ │ │ │ │ ├── BlacklistUtil.kt │ │ │ │ │ │ ├── CodecUtil.kt │ │ │ │ │ │ ├── CoilConfig.kt │ │ │ │ │ │ ├── DanmakuRateLimiter.kt │ │ │ │ │ │ ├── DeviceUtil.kt │ │ │ │ │ │ ├── EnumExtends.kt │ │ │ │ │ │ ├── Extends.kt │ │ │ │ │ │ ├── LiveStreamUrlFetcher.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 │ │ │ │ │ ├── live/ │ │ │ │ │ │ └── LiveViewModel.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_banner_foreground_2.xml │ │ │ │ ├── ic_danmaku_count.xml │ │ │ │ ├── ic_gamer_ani.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ ├── ic_launcher_foreground_2.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_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 │ │ │ ├── CommentItem.kt │ │ │ ├── CommentPanel.kt │ │ │ ├── DescriptionPanel.kt │ │ │ ├── FullscreenImageViewer.kt │ │ │ ├── GeetestTvVerifyDialog.kt │ │ │ ├── LibVLCDownloaderDialog.kt │ │ │ ├── LoadingTip.kt │ │ │ ├── RemoteControlPanelDemo.kt │ │ │ ├── SubCommentItem.kt │ │ │ ├── SubCommentPanel.kt │ │ │ ├── TopNav.kt │ │ │ ├── TvAlertDialog.kt │ │ │ ├── UpIcon.kt │ │ │ ├── UserPanel.kt │ │ │ ├── buttons/ │ │ │ │ ├── CoinButton.kt │ │ │ │ ├── FavoriteButton.kt │ │ │ │ ├── LikeButton.kt │ │ │ │ ├── SeasonInfoButtons.kt │ │ │ │ └── ToViewButton.kt │ │ │ ├── live/ │ │ │ │ └── LiveRoomCard.kt │ │ │ ├── pgc/ │ │ │ │ └── IndexFilter.kt │ │ │ ├── search/ │ │ │ │ ├── SearchKeyword.kt │ │ │ │ └── SoftKeyboard.kt │ │ │ ├── settings/ │ │ │ │ ├── SettingListItem.kt │ │ │ │ ├── SettingNumberListItem.kt │ │ │ │ ├── SettingSwitchListItem.kt │ │ │ │ ├── SettingsMenuSelectItem.kt │ │ │ │ └── UpdateDialog.kt │ │ │ └── videocard/ │ │ │ ├── LargeVideoCard.kt │ │ │ ├── SeasonCard.kt │ │ │ ├── SmallVideoCard.kt │ │ │ ├── TabbedVideosPanel.kt │ │ │ └── VideosRow.kt │ │ ├── manager/ │ │ │ ├── FollowStateManager.kt │ │ │ ├── PlayedAidsCache.kt │ │ │ └── VideoUserActionManager.kt │ │ ├── screens/ │ │ │ ├── MainScreen.kt │ │ │ ├── RegionBlockScreen.kt │ │ │ ├── SeasonInfoScreen.kt │ │ │ ├── TagScreen.kt │ │ │ ├── VideoInfoScreen.kt │ │ │ ├── VideoPlayerV3Screen.kt │ │ │ ├── login/ │ │ │ │ ├── AppQRLoginContent.kt │ │ │ │ ├── LoginScreen.kt │ │ │ │ └── SmsLoginContent.kt │ │ │ ├── main/ │ │ │ │ ├── DrawerContent.kt │ │ │ │ ├── HomeContent.kt │ │ │ │ ├── LiveContent.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/ │ │ │ │ ├── UgcChildRegionButtons.kt │ │ │ │ ├── UgcCommon.kt │ │ │ │ ├── UgcContentFactory.kt │ │ │ │ └── UgcStateManager.kt │ │ │ ├── search/ │ │ │ │ ├── SearchInputScreen.kt │ │ │ │ ├── SearchResultFilter.kt │ │ │ │ └── SearchResultScreen.kt │ │ │ ├── settings/ │ │ │ │ ├── LogsScreen.kt │ │ │ │ ├── MediaCodecScreen.kt │ │ │ │ ├── SettingsScreen.kt │ │ │ │ ├── SpeedTestScreen.kt │ │ │ │ └── content/ │ │ │ │ ├── AboutSetting.kt │ │ │ │ ├── InfoSetting.kt │ │ │ │ ├── NetworkSetting.kt │ │ │ │ ├── OtherSetting.kt │ │ │ │ ├── PlayerSetting.kt │ │ │ │ ├── PlayerTypeSetting.kt │ │ │ │ ├── StorageSetting.kt │ │ │ │ └── UISetting.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/ │ │ ├── NavItemsExtensions.kt │ │ ├── PlayerActivityUtil.kt │ │ ├── ProvideListBringIntoViewSpec.kt │ │ └── TvLazyListFocusRestorer.kt │ └── res/ │ └── values/ │ ├── dimens.xml │ ├── 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/ │ │ ├── BiliApiConstants.kt │ │ ├── entity/ │ │ │ ├── ApiType.kt │ │ │ ├── CarouselData.kt │ │ │ ├── CodeType.kt │ │ │ ├── Favorite.kt │ │ │ ├── Picture.kt │ │ │ ├── PlayData.kt │ │ │ ├── danmaku/ │ │ │ │ └── DanmakuMask.kt │ │ │ ├── home/ │ │ │ │ └── RecommendData.kt │ │ │ ├── live/ │ │ │ │ ├── LiveArea.kt │ │ │ │ ├── LiveFollowing.kt │ │ │ │ ├── LiveRecommend.kt │ │ │ │ ├── LiveRoom.kt │ │ │ │ └── LiveRoomPlayInfo.kt │ │ │ ├── login/ │ │ │ │ ├── Captcha.kt │ │ │ │ ├── QR.kt │ │ │ │ └── Sms.kt │ │ │ ├── pgc/ │ │ │ │ ├── PgcFeedData.kt │ │ │ │ ├── PgcItem.kt │ │ │ │ ├── PgcType.kt │ │ │ │ └── index/ │ │ │ │ ├── IndexParams.kt │ │ │ │ ├── PgcIndexCondition.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 │ │ │ ├── BiliHttpConstants.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 │ │ │ │ │ ├── ArchiveRelation.kt │ │ │ │ │ ├── GaiaVgateData.kt │ │ │ │ │ ├── PlayUrlResponse.kt │ │ │ │ │ ├── PopularVideosResponse.kt │ │ │ │ │ ├── RelatedVideosResponse.kt │ │ │ │ │ ├── SetVideoFavorite.kt │ │ │ │ │ ├── Tag.kt │ │ │ │ │ ├── Timeline.kt │ │ │ │ │ ├── UgcSeason.kt │ │ │ │ │ ├── VideoDetail.kt │ │ │ │ │ ├── VideoInfo.kt │ │ │ │ │ ├── VideoMoreInfo.kt │ │ │ │ │ ├── VideoOnlineTotal.kt │ │ │ │ │ └── VideoShot.kt │ │ │ │ └── web/ │ │ │ │ ├── Hover.kt │ │ │ │ └── Nav.kt │ │ │ ├── plugins/ │ │ │ │ └── BiliUserAgent.kt │ │ │ └── util/ │ │ │ ├── ApiSign.kt │ │ │ ├── BiliAppConf.kt │ │ │ ├── BiliDns.kt │ │ │ ├── BiliWebConf.kt │ │ │ ├── Brotli.kt │ │ │ ├── Buvid.kt │ │ │ ├── CommonEnumIntSerializer.kt │ │ │ └── Zlib.kt │ │ ├── repositories/ │ │ │ ├── AuthRepository.kt │ │ │ ├── BiliApiModule.kt │ │ │ ├── ChannelRepository.kt │ │ │ ├── CoinRepository.kt │ │ │ ├── CommentRepository.kt │ │ │ ├── FavoriteRepository.kt │ │ │ ├── HistoryRepository.kt │ │ │ ├── LikeRepository.kt │ │ │ ├── LiveRepository.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 ├── doc/ │ └── 弹幕/ │ ├── calc_danmaku_averages.js │ ├── 弹幕code review 报告.md │ ├── 弹幕库优化.md │ ├── 弹幕重构需求.md │ └── 重构后.txt ├── 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/ │ │ │ ├── danmaku/ │ │ │ │ ├── CacheManager.kt │ │ │ │ ├── DanmakuConfig.kt │ │ │ │ ├── DanmakuEngine.kt │ │ │ │ ├── DanmakuLogStats.kt │ │ │ │ ├── DanmakuPlayer.kt │ │ │ │ ├── DanmakuTimer.kt │ │ │ │ ├── DanmakuView.kt │ │ │ │ └── model/ │ │ │ │ ├── Danmaku.kt │ │ │ │ ├── DanmakuItem.kt │ │ │ │ ├── DanmakuKind.kt │ │ │ │ └── RenderSnapshot.kt │ │ │ ├── entity/ │ │ │ │ ├── Audio.kt │ │ │ │ ├── ControllerButtonConfig.kt │ │ │ │ ├── DanmakuSize.kt │ │ │ │ ├── DanmakuTransparency.kt │ │ │ │ ├── DanmakuType.kt │ │ │ │ ├── DefaultStartPosition.kt │ │ │ │ ├── LiveCodec.kt │ │ │ │ ├── PlayMode.kt │ │ │ │ ├── PortraitVideoFixMode.kt │ │ │ │ ├── RequestState.kt │ │ │ │ ├── Resolution.kt │ │ │ │ ├── VideoAspectRatio.kt │ │ │ │ ├── VideoCodec.kt │ │ │ │ ├── VideoListItem.kt │ │ │ │ ├── VideoPlayerClosedCaptionMenuItem.kt │ │ │ │ ├── VideoPlayerDanmakuMenuItem.kt │ │ │ │ ├── VideoPlayerData.kt │ │ │ │ ├── VideoPlayerMenuNavItem.kt │ │ │ │ ├── VideoPlayerOthersMenuItem.kt │ │ │ │ ├── VideoPlayerPictureMenuItem.kt │ │ │ │ └── VideoRotation.kt │ │ │ ├── seekbar/ │ │ │ │ ├── SeekBar.kt │ │ │ │ ├── SeekBarThumb.kt │ │ │ │ └── SeekMoveState.kt │ │ │ └── util/ │ │ │ ├── DanmakuMaskFinder.kt │ │ │ ├── DanmakuMaskModifiers.kt │ │ │ └── VideoShotExtends.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── ic_danmaku_hide.xml │ │ │ ├── ic_danmaku_on.xml │ │ │ ├── ic_play_mode_custom.xml │ │ │ ├── ic_play_mode_list_order.xml │ │ │ ├── ic_play_mode_list_order_reverse.xml │ │ │ ├── ic_play_mode_part_and_episode.xml │ │ │ ├── ic_play_mode_part_and_episode_reverse.xml │ │ │ ├── ic_play_mode_related_video.xml │ │ │ ├── ic_play_mode_single.xml │ │ │ ├── ic_play_mode_single_loop.xml │ │ │ ├── ic_subtitle_off.xml │ │ │ ├── ic_subtitle_on.xml │ │ │ ├── next_play_fill.xml │ │ │ ├── person.xml │ │ │ └── person_following.xml │ │ └── 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 │ ├── LiveViewerCountTip.kt │ ├── MenuController.kt │ ├── OnlineViewerCountTip.kt │ ├── PlayStateTips.kt │ ├── SeekController.kt │ ├── SkipTip.kt │ ├── UserActionContent.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/ ├── DateExtends.kt ├── Debounce.kt ├── FirebaseUtil.kt ├── FocusRequesterExtends.kt ├── ImageExtends.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 if: github.repository == 'aaa1115910/bv' 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(或release) 版本,如果问题依然存在再提交 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 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 == 'aaa1115910/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 == 'aaa1115910/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 .idea .gradle /local.properties .DS_Store build /captures .externalNativeBuild .cxx /signing.properties /.idea/jarRepositories.xml /.idea/migrations.xml /.idea/codeStyles/ .kotlin .vscode buildSrc bin keystore.jks signing.properties *.log /app/default/ ================================================ FILE: .gitmodules ================================================ [submodule "libs"] path = libs url = https://github.com/aaa1115910/bv-libs.git ================================================ FILE: CHANGELOG.md ================================================ [![Downloads](https://img.shields.io/github/downloads/fantasytyx/bv/total?cacheSeconds=3600)](https://github.com/fantasytyx/bv/releases) - 主屏(整体框架以及首页、UGC、PGC) - 重构左侧导航栏,移除抽屉展开效果 - 重写首页列表、UGC列表,优化性能 - 首页、ugc:标题改为2行、缩减视频列表的间距 - 首页、ugc:视频卡片显示发布时间 - 首页、ugc:视频卡片修改选中效果 - 记住每个内容页当前选中的Tab并在切换回来后恢复 - 支持按返回键回到左侧菜单栏 - 首页、ugc、pgc:顶部Tab切换增加防抖,延迟发起内容和请求 - 首页、ugc、pgc:简化内容区切换动画;让容器填充满屏幕,避免竖向的收缩动画 - 首页、ugc、pgc:按返回键定位到顶部tab时,内容区不滚动到顶部(点击tab刷新数据会回到顶部) - UGC:缓存每个tab的数据,减少请求次数 - UGC:去掉功能并没实现的子分类 - 左侧菜单项未获得焦点的时候,去掉选中效果 - 左侧菜单切换增加防抖,延迟切换右侧内容 - 把“浏览历史、我的收藏、我的追番、稍后再看”整合到“首页”下面 - 点击左侧菜单的“用户头像”改成进入“用户中心”页面 - 优化各个列表焦点元素停留位置,让能显示更多完整项 - 解决番剧时间线页面无法显示的问题 - 首页的推荐、热门、动态、历史、收藏、稍后再看列表、UGC列表以及UGC视频推荐列表,UGC视频卡片增加长按确认键进入up主空间页面 - 动态,聚焦在视频卡片上时,按菜单键打开UP关注列表页 - 动态、up空间、视频推荐,在充电视频的UGC视频卡片右上角增加闪电图标(web接口) - 去掉列表顶部tab的padding动画 - 播放详情页 - 支持不显示UGC视频详情页,直接播放 - 增加点赞、投币 - 调整收藏、简介入口位置 - 修改视频封面,合集中的视频改成显示视频封面。原逻辑合集中的视频显示合集的封面 - 选中封面增加边框 - 多个收藏夹时,弹窗让用户选择要添加到哪个收藏夹,仅单个收藏夹时不弹窗直接加入默认收藏夹 - 视频详情页增加实例管理逻辑,最多保留3个实例,优化内存占用,同时也更少返回次数就能回到列表页 - 回退页面的时不请求视频关联的用户数据(点赞、投币、收藏),减少请求时有必要的 - 修改合集弹窗列表的序号错误 - 左上角视频封面图的右下角增加视频时长 - 视频卡片中的视频时长新增小时部分,小时部分在时长超过60分钟时出现 - 背景图从优先取合集封面改成取视频封面 - 背景图显隐增加淡入淡出动画 - 播放页 - 增加“推荐视频” - 操作方式: 1)双击下键; 2)按下键显示视频信息,移动焦点在底部那排按钮后再按下键 - 播放器控制条增加点赞、收藏、投币(仅UGC视频且要登录才会显示) - 播放器控制条,默认聚焦在进度条 - 此时,按确认键会触发“播放/暂停”、按左右键回触发“快进/快退” - 播放器控制条,增加功能按钮(播放速度、up空间、旋转画面、字幕开关、刷新当前视频、弹幕开关、播放清单、推荐视频、播放器设置、循环播放) - UGC视频才会显示 up空间入口。会根据是否已关注显示不同的图标 - 有字幕才显示 字幕开关 - 新增播放器底部常驻进度条功能和配置 - 视频信息,调小标题字体、调小进度条高度、调浅缓冲进度颜色 - 播放速度调成"画面音频"子菜单的第一个 - 播放速度生效周期改成单个视频,即切视频就重置为1(原版播放速度是全局存储的) - 播放速度增加倍数 2.25、2.5、2.75、3 - 音频编码调到画面比例前面 - 弹幕设置默认值改成 字体缩放110%、不透明度80%、显示区域20% - 增加按下返回键隐藏控制条 - 增加快进/快退一段时间无操作自动确认播放 - 控制条中的视频时长新增小时部分,小时部分在时长超过60分钟时出现 - 支持“竖屏视频播放时的最大清晰度为1080P”(解决部分设备竖屏视频变形的问题) - 支持“都播完后退出播放器”,退出后是回到视频详情页 - 支持隐藏视频播放页面左下角的视频调试信息 - 优化视频缓冲逻辑,缓解卡面卡死 - 支持当前视频播放完后不播放下一个分P视频或合集视频 - 给“自动播放下一个视频和自动退出”增加提示。倒数结束前可按任意键取消执行 - 调整标题显示样式,正确显示视频的标题(投稿名称)和副标题(分p名称)。(如果视频只有单P时不显示副标题) - 修复字幕错乱bug - UGC视频的视频信息增加up主名称、播放数、弹幕数、收藏数、投币数、点赞数、发布时间 - 新增支持切换分P/分集视频后返回到当前视频的详情页 - 修复非AI自动生成的字幕加载失败的问题 - 播放器控制条,默认聚焦在进度条,按左右键“快进/快退”、按确认键“暂停/播放” - 无法播放的视频不上报历史 - 新增视频画面旋转功能 - 搜索 - 搜索结果页视频卡片显示发布时间 - 支持按返回键回到左侧菜单栏 - 搜索结果页改成选中tab的时候才发起请求,以便减少请求次数 - up空间页 - 视频卡片显示发布时间 - 增加 关注up 功能 - 修改页面历史记录,允许记录最多两个不同up的空间页 - 收藏 - 视频卡片显示 收藏时间 - 浏览历史 - 视频卡片显示 浏览时间 - 标签搜索结果页 - 视频卡片显示发布时间 - 设置页面 - 增加分类“播放设置” - 界面设置-增加“首页默认标签”设置,默认“推荐” - 可以修改打开应用时首页默认选中的标签 - 播放设置-增加是否“显示UGC视频详情页”设置,默认显示 - 关闭后,点击非PGC视频卡片不显示详情页直接开始播放 - 播放设置-增加设置“显示视频加载信息”,默认不显示 - 播放设置-增加设置“设置竖屏视频播放时的最大清晰度为1080P”,默认禁用 - 开启可解决部分设备竖屏视频变形/花屏的问题 - 播放设置-增加是否“自动播放下一个视频”设置,默认开启 - 播放设置-增加是否“都播完后退出播放器”设置,默认开启 - 播放设置-增加默认播放速度配置,默认1倍 - 播放设置-增加快进时间间隔配置,默认10秒 - 播放设置-增加快退时间间隔配置,默认5秒 - 播放设置-增加是否显示“播放器底部常驻进度条”配置,默认不显示 - 关于-更新,优化应用更新弹窗在内容很多时支持滚动查看 - 关于-更新,使用github镜像源加速下载 - 其它 - 播放量改成Long类型,解决追番列表无法显示(凡人播放量超过Int类型的最大值) ================================================ 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~~ [![Android Sdk Require](https://img.shields.io/badge/Android-6.0%2B-informational?logo=android)](https://apilevels.com/#:~:text=Jetpack%20Compose%20requires%20a%20minSdk%20of%2021%20or%20higher) [![GitHub](https://img.shields.io/github/license/fantasytyx/bv)](https://github.com/fantasytyx/bv) **BV 无法在中国大陆地区内的智能电视上使用,如有相关使用需求请使用 [云视听小电视](https://app.bilibili.com)** **禁止在中国境内传播、宣传、分发 BV**
--- BV ~~(Bug Video)~~ 是一款 [哔哩哔哩](https://www.bilibili.com) 的第三方应用,适配 `Android 移动端` 和 `Android TV`,使用 `Jetpack Compose` 开发 **都是随心乱写的代码,能跑就行。** ---
# 学废了
## 声明 **此项目是个人为了学习安卓开发而fork, 仅用于学习和测试,禁止在中国境内传播、宣传、分发,如有相关使用需求请使用 [哔哩哔哩官方APP](https://app.bilibili.com),否则后果自负** ## 修改 在原bv的基础上做了一些修改,包括: - 把“浏览历史、我的收藏、我的追番、稍后再看”整合到“首页”下面 - 增加“首页默认标签”设置 (设置-界面设置,默认“推荐”) - **可以修改打开应用时首页默认选中的标签**,选项有:推荐、热门、动态、历史、收藏、追番、稍后再看 - 首页推荐、热门、动态、历史、收藏、稍后再看,UGC列表以及UGC视频推荐列表,可以**在UGC视频卡片长按确认键进入up主空间页面**看up的所有投稿视频 - **动态页面**,聚焦在视频卡片上时,**按菜单键打开已关注UP列表页**,可以筛选想看的up - 动态、up空间、视频推荐,在充电视频的UGC视频卡片右上角增加闪电图标(web接口) - 在主屏右上角显示当前时间 - 首页导航项、UGC导航项、PGC导航项支持自定义排序和隐藏 - 设置-界面设置 - **添加直播**,推荐、关注、分区,直播搜索,直播弹幕 - UGC详情页、PGC详情页、UGC&PGC视频播放页 增加评论功能 - 支持两种导航切换模式:聚焦后自动切换、聚焦并确认才切换 - 支持长按确认键加速播放 - 浏览历史、收藏、追番、稍后再看 这4个列表 新增删除支持。按菜单键进入删除模式,长按(或短按)确认键删除当前选中项,按返回键退出删除模式 ![首页](https://github.com/user-attachments/assets/f621d34b-d618-4ab2-a0ef-663cc8970664) - **UGC视频详情页增加点赞、投币功能** - 增加是否“显示UGC视频详情页”设置 (默认显示) - 关闭后,点击UGC视频卡片会**跳过详情页直接开始播放** - 合集/分P 自动滚动到最后播放的视频并高亮显示 ![UGC详情](https://github.com/user-attachments/assets/bdd6bfe7-b434-4f59-819d-49c2d002ff34) - **播放器页面增加“推荐视频”、“视频列表”** - 操作方式: 1)双击下方向键; 2)按下键显示视频信息,移动焦点在底部那排按钮后再按下方向键 ![视频播放-推荐视频](https://github.com/user-attachments/assets/b62d1c6e-0a4f-4e39-a0c9-d3f06462d3e5) - **新增视频画面旋转功能** - 播放器控制条,**增加点赞、收藏、投币** - 仅UGC视频且要登录才会显示 - 播放器控制条,默认聚焦在进度条 - 此时,按确认键会触发“播放/暂停”、按左右键回触发“快进/快退” - 播放器控制条,增加功能按钮(播放速度、画质、up空间、画面旋转、字幕开关、重新加载当前视频、弹幕开关、循环播放、播放清单、推荐视频、视频简介、播放器设置) - 新增识别字幕类型,添加AI标识 - 播放器控制条,支持设置按钮的顺序、显隐、默认焦点 - 支持PGC视频自动跳过片头/片尾设置 - 支持播放只有音轨的视频 - 换成新版弹幕接口 ![视频播放](https://github.com/user-attachments/assets/8f14a371-4ff6-479e-9c25-2fe3868b1db6) - 调整设置,增加分类“播放设置” - 把 分辨率、视频编码、音频编码、启用音频软件 4个设置移入这个分类 - 增加是否“显示UGC视频详情页”设置 (默认显示) - 增加是否在播放页面底部 常驻“显示**迷你进度条**”设置(默认不显示) - 增加“显示视频加载过程信息”设置(默认不显示) - **增加“竖屏视频播放异常时的处理**方式”设置(默认不处理) - 不是所有设备都有问题,没问题的同学不要开; - 使用TextureView模式卡的不行的,建议用限制到1080P的模式 - **增加“下一个播放”设置**(默认不播放),可设置为: - 不播 - 播推荐视频 - 播剧集和分P的下一个 - 播播剧集和分P的下一个或推荐视频 - 增加是否“都播完后退出播放器”设置(默认开启) - 增加默认播放速度设置(默认1倍) - 增加快进时间间隔设置(默认10秒) - 增加快退时间间隔设置(默认5秒) - 增加显示在线观看人数(默认一直显示,可选不显示、30秒后隐藏) - 增加开始播放位置设置(默认从头开始播放,可选从历史位置播放) - 新增PGC视频自动跳过片头/片尾设置(默认关闭) - 新增 播放器控制按钮支持排序、显隐、设置默认焦点 - 新增 点播与直播的弹幕过滤等级设置 - 新增 播放器页面长按确认键的执行动作的设置,可选:打开菜单、加速播放 - 新增 长按确认键加速播放加速值的设置,默认2x - 界面设置 - 新增 界面模式设置,选项有:启动时自动检测、强制使用 TV,或强制使用 Mobile 界面(默认自动检测) - 新增页面浏览历史相关的设置:UGC 视频详情页面的历史记录数量、详情页历史记录是否包含播放器打开的详情页、UGC 视频播放页面的历史记录数量 - 新增 UGC导航项设置,可修改顺序和显隐 - 新增 PGC导航项设置,可修改顺序和显隐 - 新增 直播导航项设置,可修改顺序和显隐 - 新增 导航切换模式设置,选项有:聚焦后自动切换、聚焦并确认才切换 - 网络设置 - 新增 仅允许IPV4的选项 ![设置](https://github.com/user-attachments/assets/5e721ec3-e584-4233-a112-e7a3ee5f1afd) - 优化up空间页,丰富内容并增加关注功能 - 优化已关注up列表页,增加本地搜索 - 优化搜索页面、账号管理页面 - 优化列表、优化视频卡片显示更多内容、精简动画、增加数据缓存、减少非必要的请求 - 按自己的喜好调整页面的布局、元素大小、交互方式、原有功能 - 解决一些bug等等 ## 构建 自己动手丰衣足食 - 安装开发环境 - Android studio、Android SDK、JAVA等等 - 补全构建需要的文件 - 在项目根目录用使用 Android SDK 中的 keytool 工具创建签名文件 keystore.jks。 ```sh keytool -genkey -v -keystore keystore.jks -alias 别名 -keyalg RSA -keysize 2048 -validity 10000 ``` 命令说明: - genkey: 生成密钥对 - -v: 详细输出 - -keystore keystore.jks: 指定生成的密钥库文件名 - -alias 别名: 指定密钥的别名(可以根据需要修改) - -keyalg RSA: 使用 RSA 算法 - -keysize 2048: 密钥长度为 2048 位 - -validity 10000: 密钥的有效期为 10000 天(约 27 年) 执行此命令后,会提示你输入: - 密钥库密码(keystore.pwd) - 密钥密码(keystore.alias_pwd),可以与密钥库密码相同 - 姓名、组织单位、城市等信息,可空 - 在项目根目录增加 signing.properties 文件。文件内容如下 ```properties keystore.path=./keystore.jks keystore.pwd=创建签名文件时设置的密码 keystore.alias=创建签名文件时设置的别名 keystore.alias_pwd=创建签名文件时设置的别名密码 ``` 2. 执行构建命令来生成 apk 文件 ```sh # release ./gradlew clean assembleRelease ``` - 在根目录增加 signing.properties 文件。文件内容如下 ```properties keystore.path=./keystore.jks keystore.pwd=创建签名文件时设置的密码 keystore.alias=创建签名文件时设置的别名 keystore.alias_pwd=创建签名文件时设置的别名密码 ``` - 执行构建命令来生成 apk 文件 ```sh # release ./gradlew clean assembleRelease ``` ## 安装 ### adb ./adb.exe connect 192.168.xx.xx ./adb.exe -s 192.168.xx.xx install -r -d {apk文件路径} ### Release - [Github Release](https://github.com/fantasytyx/bv/releases) ## 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 java.io.FileInputStream import java.util.Properties plugins { alias(gradleLibs.plugins.android.application) alias(gradleLibs.plugins.compose.compiler) alias(gradleLibs.plugins.google.ksp) alias(gradleLibs.plugins.kotlin.android) alias(gradleLibs.plugins.kotlin.serialization) } 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.applicationId 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 isShrinkResources = true // 移除未使用的资源 proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) if (signingProp.exists()) signingConfig = signingConfigs.getByName("key") } debug { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) applicationIdSuffix = ".debug" } create("r8Test") { isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) applicationIdSuffix = ".r8test" if (signingProp.exists()) signingConfig = signingConfigs.getByName("key") } create("alpha") { isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) if (signingProp.exists()) signingConfig = signingConfigs.getByName("key") } } buildFeatures { compose = true //buildConfig = true } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "**/*.proto" excludes += "**/*.kotlin_metadata" excludes += "**/kotlin/**" excludes += "**/*.txt" excludes += "**/*.version" } // 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")) implementation(project(":app:shared")) } tasks.withType { useJUnitPlatform() } ================================================ FILE: app/compose_compiler_config.conf ================================================ kotlin.collections.* kotlin.time.Duration kotlinx.coroutines.CoroutineScope androidx.paging.compose.LazyPagingItems # 核心稳定性 androidx.compose.runtime.Composable androidx.compose.runtime.State androidx.compose.ui.Modifier # TV 特定 androidx.tv.material3.Button androidx.tv.material3.Card androidx.tv.material3.Surface # 项目特定 dev.aaa1115910.bv.tv.screens.main.* ================================================ 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.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 = false // 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 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() 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 -> dev.aaa1115910.biliapi.BiliApiConstants.USER_AGENT_WEB ApiType.App -> dev.aaa1115910.biliapi.BiliApiConstants.USER_AGENT_APP }, 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 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() if (isFinishing) { playerViewModel.releasePlayerResources("onDestroy") } } override fun onPause() { playerViewModel.videoPlayer?.isInBackground = true playerViewModel.videoPlayer?.pause() super.onPause() } } ================================================ 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.medium), 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.medium), 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.medium), 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.medium), 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.medium) .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.biliapi.entity.ugc.toSmartDate 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) } data.pubTime?.toLong()?.toSmartDate()?.let { Text(text = it) } } } } } } } } @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 = "3小时前" ) ================================================ 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.medium, 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.medium), contentAlignment = Alignment.BottomCenter ) { AsyncImage( modifier = coverModifier .aspectRatio(0.75f) .clip(MaterialTheme.shapes.medium) .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.medium, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) { Column { Box( modifier = Modifier.clip(MaterialTheme.shapes.medium), contentAlignment = Alignment.BottomCenter ) { AsyncImage( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f) .clip(MaterialTheme.shapes.medium), 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 * 1000L, 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.component.QrImage 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 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) { QrImage( modifier = Modifier .padding(top = 36.dp) .size(240.dp), content = 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.medium, 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.medium, 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 ) { QrImage( 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.DisposableEffect 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.player.danmaku.DanmakuView 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 dev.aaa1115910.bv.viewmodel.login.GeetestResult import com.geetest.sdk.GT3ConfigBean import com.geetest.sdk.GT3ErrorBean import com.geetest.sdk.GT3GeetestUtils import com.geetest.sdk.GT3Listener 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(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") // 外部创建 DanmakuView,与 videoPlayer 一致的模式 val danmakuView = remember { DanmakuView(context).also { playerViewModel.danmakuView = it } } DisposableEffect(danmakuView) { onDispose { danmakuView.release() } } 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() // 风控 Geetest 验证 var gt3GeetestUtils: GT3GeetestUtils? by remember { mutableStateOf(null) } val gt3ConfigBean by remember { mutableStateOf(GT3ConfigBean()) } 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) {} override fun onStatistics(p0: String?) {} override fun onSuccess(p0: String?) {} override fun onButtonClick() {} override fun onClosed(p0: Int) { playerViewModel.onGeetestCancelled() } override fun onFailed(p0: GT3ErrorBean?) { playerViewModel.onGeetestCancelled() } override fun onDialogResult(result: String) { runCatching { val geetestResult = Json.decodeFromString(result) gt3GeetestUtils?.showSuccessDialog() playerViewModel.onGeetestResult( challenge = geetestResult.geetestChallenge, validate = geetestResult.geetestValidate, seccode = geetestResult.geetestSeccode ) }.onFailure { gt3GeetestUtils?.showFailedDialog() playerViewModel.onGeetestCancelled() } } } } gt3GeetestUtils!!.init(gt3ConfigBean) onDispose { gt3GeetestUtils?.destory() } } LaunchedEffect(playerViewModel.showGeetestDialog) { if (playerViewModel.showGeetestDialog) { gt3GeetestUtils?.startCustomFlow() gt3ConfigBean.api1Json = JSONObject().apply { put("success", 1) put("gt", playerViewModel.geetestGt) put("challenge", playerViewModel.geetestChallenge) } gt3GeetestUtils?.getGeetest() } } 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, showPreviewTip = playerViewModel.showPreviewTip, ), 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, defaultStartPosition = Prefs.playerDefaultStartPosition.toPlayerType() ), 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.medium) ) .fillMaxWidth() .aspectRatio(16f / 9f), isFullScreen = isVideoFullscreen, videoPlayer = playerViewModel.videoPlayer!!, danmakuView = danmakuView, 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.medium), 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.medium.copy( bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ) ) ) .ifElse( { index == (videoDetailViewModel.videoDetail?.relatedVideos?.size ?: 0) - 1 }, Modifier.clip( MaterialTheme.shapes.medium.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: Long, 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.medium) ) .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.medium, ) { 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, liveRoomSearchResult = searchResultViewModel.liveRoomSearchResult.liveRooms ) } @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, liveRoomSearchResult: 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, liveRoomSearchResult = liveRoomSearchResult, 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(), liveRoomSearchResult = 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(), liveRoomSearchResult = 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.entity.ugc.toSmartDate 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, liveRoomSearchResult: 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 ) SearchType.LiveRoom -> LiveRoomSearchResult( biliUserList = liveRoomSearchResult ) } } } } @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.toLong().toSmartDate() ), 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.toLong().toSmartDate() ), 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, ) { } @Composable private fun LiveRoomSearchResult( 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.activities.LauncherActivity import dev.aaa1115910.bv.entity.InterfaceMode 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 import dev.aaa1115910.bv.util.Prefs @Composable fun AdvanceContent( modifier: Modifier = Modifier ) { val context = LocalContext.current val interfaceMode = Prefs.interfaceMode val interfaceModeTitle = stringResource(R.string.settings_ui_interface_mode_title) val apiTitle = stringResource(R.string.settings_item_api) LazyColumn( modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp) ) { preferenceGroups( null to { radioPreference( title = interfaceModeTitle, value = interfaceMode, values = InterfaceMode.entries.associateWith { it.getDisplayName(context) }, onValueChange = { if (it != interfaceMode) { Prefs.interfaceMode = it LauncherActivity.actionRestart(context) } } ) radioPreference( title = apiTitle, 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 ================================================ ================================================ FILE: app/shared/src/main/res/xml/network_security_config.xml ================================================ ================================================ FILE: app/shared/src/main/res/xml/provider_paths.xml ================================================ ================================================ FILE: app/shared/src/r8Test/AndroidManifest.xml ================================================ ================================================ FILE: app/shared/src/r8Test/res/values/strings.xml ================================================ BV R8 Test ================================================ FILE: app/shared/src/test/kotlin/android/util/Log.kt ================================================ package android.util class Log { companion object { @JvmStatic fun isLoggable(tag: String, level: Int): Boolean { return true } @JvmStatic fun println(priority: Int, tag: String, msg: String): Int { println("[$tag] $msg") return 0 } } } ================================================ FILE: app/shared/src/test/kotlin/dev/aaa1115910/bv/network/GithubApiTest.kt ================================================ package dev.aaa1115910.bv.network import kotlinx.coroutines.runBlocking import kotlin.test.Test class GithubApiTest { @Test fun `get latest release build`() = runBlocking { println(GithubApi.getLatestReleaseBuild()) } @Test fun `get latest pre-release build`() = runBlocking { println(GithubApi.getLatestPreReleaseBuild()) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/tv/.gitignore ================================================ /build ================================================ FILE: app/tv/build.gradle.kts ================================================ plugins { alias(gradleLibs.plugins.android.library) alias(gradleLibs.plugins.compose.compiler) alias(gradleLibs.plugins.google.ksp) alias(gradleLibs.plugins.kotlin.android) alias(gradleLibs.plugins.kotlin.serialization) } android { namespace = AppConfiguration.appId+".tv" compileSdk = AppConfiguration.compileSdk defaultConfig { minSdk = AppConfiguration.minSdk vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = true 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")) implementation(libs.ui.util) } ================================================ FILE: app/tv/consumer-rules.pro ================================================ ================================================ FILE: app/tv/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 # 设置优化轮数,可以提升代码优化效果 -optimizationpasses 5 # 允许改变访问修饰符,有助于优化 -allowaccessmodification ================================================ FILE: app/tv/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/MainActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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.core.splashscreen.SplashScreen.Companion.installSplashScreen import dev.aaa1115910.bv.repository.UserRepository import dev.aaa1115910.bv.tv.screens.MainScreen import dev.aaa1115910.bv.tv.screens.RegionBlockScreen import dev.aaa1115910.bv.tv.screens.user.lock.UnlockUserScreen import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.NetworkUtil import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.android.ext.android.inject class MainActivity : ComponentActivity() { private val userRepository: UserRepository by inject() private val logger = KotlinLogging.logger {} override fun onCreate(savedInstanceState: Bundle?) { var keepSplashScreen = true installSplashScreen().apply { setKeepOnScreenCondition { keepSplashScreen } } super.onCreate(savedInstanceState) setContent { val scope = rememberCoroutineScope() var isCheckingUserLock by remember { mutableStateOf(true) } var userLockLocked by remember { mutableStateOf(false) } LaunchedEffect(Unit) { scope.launch(Dispatchers.Default) { val user = userRepository.findUserByUid(userRepository.uid) userLockLocked = user?.lock?.isNotBlank() ?: false logger.info { "default user: ${user?.username}" } isCheckingUserLock = false keepSplashScreen = false } } BVTheme { if (isCheckingUserLock) { // 保持空白界面直到检查完成 } else { if (!userLockLocked) { MainScreen() } else { UnlockUserScreen( onUnlockSuccess = { user -> logger.info { "unlock user lock for user ${user.uid}" } userLockLocked = false } ) } } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/pgc/PgcIndexActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.pgc import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.tv.screens.main.pgc.PgcIndexScreen import dev.aaa1115910.bv.ui.theme.BVTheme class PgcIndexActivity : ComponentActivity() { companion object { fun actionStart( context: Context, pgcType: PgcType ) { context.startActivity( Intent(context, PgcIndexActivity::class.java).apply { putExtra("pgcType", pgcType.ordinal) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { PgcIndexScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/pgc/anime/AnimeTimelineActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.pgc.anime import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.main.pgc.anime.AnimeTimelineScreen import dev.aaa1115910.bv.ui.theme.BVTheme class AnimeTimelineActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { AnimeTimelineScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/search/SearchInputActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.search import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.remember import androidx.compose.ui.focus.FocusRequester import dev.aaa1115910.bv.tv.screens.search.SearchInputScreen import dev.aaa1115910.bv.ui.theme.BVTheme class SearchInputActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { val defaultFocusRequester = remember { FocusRequester() } BVTheme { SearchInputScreen(defaultFocusRequester = defaultFocusRequester) } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/search/SearchResultActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.search 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.tv.screens.search.SearchResultScreen import dev.aaa1115910.bv.ui.theme.BVTheme class SearchResultActivity : ComponentActivity() { companion object { fun actionStart(context: Context, keyword: String, enableProxy: Boolean) { context.startActivity( Intent(context, SearchResultActivity::class.java).apply { putExtra("keyword", keyword) putExtra("enableProxy", enableProxy) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { SearchResultScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/settings/LogsActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.settings import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.settings.LogsScreen import dev.aaa1115910.bv.ui.theme.BVTheme class LogsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { LogsScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/settings/MediaCodecActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.settings import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.settings.MediaCodecScreen import dev.aaa1115910.bv.ui.theme.BVTheme class MediaCodecActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { MediaCodecScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/settings/SettingsActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.settings import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.settings.SettingsScreen import dev.aaa1115910.bv.ui.theme.BVTheme class SettingsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { SettingsScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/settings/SpeedTestActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.settings import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.settings.SpeedTestScreen import dev.aaa1115910.bv.ui.theme.BVTheme class SpeedTestActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { SpeedTestScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/FavoriteActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.user.FavoriteScreen import dev.aaa1115910.bv.ui.theme.BVTheme class FavoriteActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { FavoriteScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/FollowActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.user.FollowScreen import dev.aaa1115910.bv.ui.theme.BVTheme class FollowActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { FollowScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/FollowingSeasonActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.user.FollowingSeasonScreen import dev.aaa1115910.bv.ui.theme.BVTheme class FollowingSeasonActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { FollowingSeasonScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/HistoryActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.user.HistoryScreen import dev.aaa1115910.bv.ui.theme.BVTheme class HistoryActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { HistoryScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/LoginActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.login.LoginScreen import dev.aaa1115910.bv.ui.theme.BVTheme class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { LoginScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/ToViewActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent // import dev.aaa1115910.bv.screen.user.HistoryScreen import dev.aaa1115910.bv.tv.screens.user.ToViewScreen import dev.aaa1115910.bv.ui.theme.BVTheme class ToViewActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { // HistoryScreen() ToViewScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/UserInfoActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.user.UserInfoScreen import dev.aaa1115910.bv.ui.theme.BVTheme class UserInfoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { UserInfoScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/UserLockSettingsActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user 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.tv.screens.user.lock.UserLockSettingsScreen import dev.aaa1115910.bv.ui.theme.BVTheme class UserLockSettingsActivity : ComponentActivity() { companion object { fun actionStart( context: Context, uid: Long ) { context.startActivity( Intent(context, UserLockSettingsActivity::class.java).apply { putExtra("uid", uid) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { UserLockSettingsScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/user/UserSwitchActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.user import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import dev.aaa1115910.bv.tv.screens.user.UserSwitchScreen import dev.aaa1115910.bv.ui.theme.BVTheme class UserSwitchActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { UserSwitchScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/video/RemoteControllerPanelDemoActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.video import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import dev.aaa1115910.bv.tv.component.RemoteControlPanelDemo import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs class RemoteControllerPanelDemoActivity : ComponentActivity() { companion object { fun actionStart( context: Context, avid: Long, cid: Long, title: String, partTitle: String, played: Int, fromSeason: Boolean, subType: Int? = null, epid: Int? = null, seasonId: Int? = null, isVerticalVideo: Boolean = false, proxyArea: ProxyArea = ProxyArea.MainLand, playerIconIdle: String = "", playerIconMoving: String = "", play: Long = 0, danmaku: Int = 0, like: Int = 0, coin: Int = 0, favorite: Int = 0, upName: String = "", upId: Long = 0L, upFace: String = "", pubTime: String = "" ) { context.startActivity( Intent(context, RemoteControllerPanelDemoActivity::class.java).apply { putExtra("avid", avid) putExtra("cid", cid) putExtra("title", title) putExtra("partTitle", partTitle) putExtra("played", played) putExtra("fromSeason", fromSeason) putExtra("subType", subType) putExtra("epid", epid) putExtra("seasonId", seasonId) putExtra("isVerticalVideo", isVerticalVideo) putExtra("proxy_area", proxyArea.ordinal) putExtra("playerIconIdle", playerIconIdle) putExtra("playerIconMoving", playerIconMoving) putExtra("play", play) putExtra("danmaku", danmaku) putExtra("like", like) putExtra("coin", coin) putExtra("favorite", favorite) putExtra("upName", upName) putExtra("upId", upId) putExtra("upFace", upFace) putExtra("pubTime", pubTime) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { RemoteControllerPanelDemoScreen() } } } } @Composable fun RemoteControllerPanelDemoScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current val intent = (context as Activity).intent val continueToPlayerV3 = { Prefs.showedRemoteControllerPanelDemo = true dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity.actionStart( context = context, avid = intent.getLongExtra("avid", 0), cid = intent.getLongExtra("cid", 0), title = intent.getStringExtra("title") ?: "", partTitle = intent.getStringExtra("partTitle") ?: "", played = intent.getIntExtra("played", 0), fromSeason = intent.getBooleanExtra("fromSeason", false), subType = intent.getIntExtra("subType", 0), epid = intent.getIntExtra("epid", 0), seasonId = intent.getIntExtra("seasonId", 0), isVerticalVideo = intent.getBooleanExtra("isVerticalVideo", false), proxyArea = ProxyArea.entries[intent.getIntExtra("proxy_area", 0)], playerIconIdle = intent.getStringExtra("playerIconIdle") ?: "", playerIconMoving = intent.getStringExtra("playerIconMoving") ?: "" ) context.finish() } RemoteControlPanelDemo( modifier = modifier.fillMaxSize(), onConfirm = continueToPlayerV3 ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/video/SeasonInfoActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.video 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.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.screens.SeasonInfoScreen import dev.aaa1115910.bv.ui.theme.BVTheme class SeasonInfoActivity : ComponentActivity() { companion object { fun actionStart( context: Context, epId: Int? = null, seasonId: Int? = null, proxyArea: ProxyArea = ProxyArea.MainLand ) { context.startActivity( Intent(context, SeasonInfoActivity::class.java).apply { epId?.let { putExtra("epid", epId) } seasonId?.let { putExtra("seasonid", seasonId) } putExtra("proxy_area", proxyArea.ordinal) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { SeasonInfoScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/video/TagActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.video 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.tv.screens.TagScreen import dev.aaa1115910.bv.ui.theme.BVTheme class TagActivity : ComponentActivity() { companion object { fun actionStart(context: Context, tagId: Int, tagName: String) { context.startActivity( Intent(context, TagActivity::class.java).apply { putExtra("tagId", tagId) putExtra("tagName", tagName) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { BVTheme { TagScreen() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/video/UpInfoActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.video 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.tv.screens.user.UpSpaceScreen import dev.aaa1115910.bv.ui.theme.BVTheme import java.lang.ref.WeakReference import java.util.LinkedHashMap class UpInfoActivity : ComponentActivity() { companion object { // 允许最多 N 个不同 mid 的页面共存 private const val MAX_SCREENS = 2 // LinkedHashMap 保持插入顺序:最早插入的条目位于 entrySet().iterator().next() private val activityByMid = LinkedHashMap>() fun actionStart(context: Context, mid: Long, name: String, face: String) { if (mid <= 0) return context.startActivity( Intent(context, UpInfoActivity::class.java).apply { putExtra("mid", mid) putExtra("name", name) putExtra("face", face) } ) } } private var mid: Long = -1L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mid = intent.getLongExtra("mid", -1L) if (mid <= 0) { finish() return } synchronized(activityByMid) { // 清理失效引用 val it = activityByMid.entries.iterator() while (it.hasNext()) { val entry = it.next() val act = entry.value.get() if (act == null || act.isFinishing) it.remove() } // 若已存在当前 mid,先关闭旧实例并移除,使重新插入刷新其“最近”位置 activityByMid[mid]?.get()?.let { old -> if (old !== this && !old.isFinishing) old.finish() } activityByMid.remove(mid) activityByMid[mid] = WeakReference(this) // 超过最大不同 mid 数量:按插入顺序移除最早的非当前 mid while (activityByMid.size > MAX_SCREENS) { val iterator = activityByMid.entries.iterator() var removed = false while (iterator.hasNext()) { val oldest = iterator.next() if (oldest.key == mid) continue // 跳过当前,找真正最早的其它 mid oldest.value.get()?.let { act -> if (!act.isFinishing) act.finish() } iterator.remove() removed = true break } if (!removed) break // 只剩当前 mid } } setContent { BVTheme { UpSpaceScreen() } } } override fun onDestroy() { super.onDestroy() synchronized(activityByMid) { val ref = activityByMid[mid] if (ref?.get() == this || ref?.get() == null) activityByMid.remove(mid) val it = activityByMid.entries.iterator() while (it.hasNext()) { val entry = it.next() val act = entry.value.get() if (act == null || act.isFinishing) it.remove() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/video/VideoInfoActivity.kt ================================================ package dev.aaa1115910.bv.tv.activities.video 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.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.screens.VideoInfoScreen import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import java.lang.ref.WeakReference import java.util.LinkedList class VideoInfoActivity : ComponentActivity() { companion object { // 使用WeakReference防止内存泄漏,避免持有已销毁Activity的强引用 private val activityQueue = LinkedList>() fun actionStart( context: Context, aid: Long, cid: Long? = null, fromSeason: Boolean = false, fromPlayer: Boolean = false, proxyArea: ProxyArea = ProxyArea.MainLand ) { context.startActivity( Intent(context, VideoInfoActivity::class.java).apply { putExtra("aid", aid) putExtra("cid", cid) putExtra("fromSeason", fromSeason) putExtra("fromPlayer", fromPlayer) putExtra("proxy_area", proxyArea.ordinal) } ) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val fromPlayer = intent.getBooleanExtra("fromPlayer", false) val shouldRecordInHistoryQueue = !fromPlayer || Prefs.videoInfoHistoryIncludeFromPlayer // 将当前活动加入队列 if (shouldRecordInHistoryQueue) { synchronized(activityQueue) { val maxVideoInfoScreens = Prefs.ugcVideoInfoHistoryCount.coerceAtLeast(1) // 清理队列中的无效引用 - 这步是必要的 // 1. 确保队列大小计算准确,防止误判是否达到历史留存上限 // 2. 处理可能未正常触发onDestroy的情况(如系统回收、应用崩溃等) // 3. 防止队列中累积无效引用导致内存泄漏 val iterator = activityQueue.iterator() while (iterator.hasNext()) { val activityRef = iterator.next() val activity = activityRef.get() if (activity == null || activity.isFinishing) { iterator.remove() } } // 添加当前活动到队列 activityQueue.add(WeakReference(this)) // 如果队列超过了最大限制,关闭最早的活动 if (activityQueue.size > maxVideoInfoScreens) { // 移除最早的活动引用 val oldestActivityRef = activityQueue.removeFirst() val oldestActivity = oldestActivityRef.get() // 确保在主线程调用finish() oldestActivity?.runOnUiThread { oldestActivity.finish() } } } } setContent { BVTheme( forceDark = true ) { VideoInfoScreen() } } } override fun onDestroy() { super.onDestroy() // 当活动被销毁时,从队列中移除该Activity的引用 synchronized(activityQueue) { val iterator = activityQueue.iterator() while (iterator.hasNext()) { val ref = iterator.next() if (ref.get() == this || ref.get() == null) { iterator.remove() } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/activities/video/VideoPlayerV3Activity.kt ================================================ package dev.aaa1115910.bv.tv.activities.video import android.content.Context import android.content.Intent import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.lifecycle.lifecycleScope import java.lang.ref.WeakReference import java.util.LinkedList import kotlinx.coroutines.delay import kotlinx.coroutines.launch import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.PlayerType import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.player.VideoPlayerOptions import dev.aaa1115910.bv.player.impl.exo.ExoPlayerFactory import dev.aaa1115910.bv.tv.screens.VideoPlayerV3Screen import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.viewmodel.VideoPlayerV3ViewModel import io.github.oshai.kotlinlogging.KotlinLogging import org.koin.androidx.viewmodel.ext.android.viewModel class VideoPlayerV3Activity : ComponentActivity() { companion object { private val logger = KotlinLogging.logger { } // 使用WeakReference防止内存泄漏,避免持有已销毁Activity的强引用 private val activityQueue = LinkedList>() private fun formatPopularity(count: Int): String { return when { count >= 100_000_000 -> String.format("%.1f亿人气", count / 100_000_000.0) count >= 10_000 -> String.format("%.1f万人气", count / 10_000.0) else -> "${count}人气" } } /** * 启动直播播放 */ fun actionStartLive( context: Context, roomId: Int, title: String, upName: String = "", watchedNum: Int = 0, upId: Long = 0L, upFace: String = "" ) { val runtime = Runtime.getRuntime() val usedMemory = runtime.totalMemory() - runtime.freeMemory() val maxMemory = runtime.maxMemory() logger.info { "Current memory usage VideoPlayerV3Activity.actionStartLive: ${usedMemory / 1024 / 1024} MB / ${maxMemory / 1024 / 1024} MB" } context.startActivity( Intent( context, VideoPlayerV3Activity::class.java ).apply { putExtra("isLive", true) putExtra("liveRoomId", roomId) putExtra("title", title) putExtra("upName", upName) putExtra("liveWatchedNum", watchedNum) putExtra("upId", upId) putExtra("upFace", upFace) } ) } fun actionStart( context: Context, avid: Long, cid: Long, title: String, partTitle: String, played: Int, fromSeason: Boolean, subType: Int? = null, epid: Int? = null, seasonId: Int? = null, isVerticalVideo: Boolean = false, proxyArea: ProxyArea = ProxyArea.MainLand, playerIconIdle: String = "", playerIconMoving: String = "", play: Long = 0, danmaku: Int = 0, like: Int = 0, coin: Int = 0, favorite: Int = 0, upName: String = "", upId: Long = 0L, upFace: String = "", pubTime: String = "" ) { // 获取当前内存信息并打印到控制台 val runtime = Runtime.getRuntime() val usedMemory = runtime.totalMemory() - runtime.freeMemory() val maxMemory = runtime.maxMemory() logger.info { "Current memory usage VideoPlayerV3Activity.actionStart: ${usedMemory / 1024 / 1024} MB / ${maxMemory / 1024 / 1024} MB" } context.startActivity( Intent( context, dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity::class.java ).apply { putExtra("avid", avid) putExtra("cid", cid) putExtra("title", title) putExtra("partTitle", partTitle) putExtra("played", played) putExtra("fromSeason", fromSeason) putExtra("subType", subType) putExtra("epid", epid) putExtra("seasonId", seasonId) putExtra("isVerticalVideo", isVerticalVideo) putExtra("proxy_area", proxyArea.ordinal) putExtra("playerIconIdle", playerIconIdle) putExtra("playerIconMoving", playerIconMoving) putExtra("play", play) putExtra("danmaku", danmaku) putExtra("like", like) putExtra("coin", coin) putExtra("favorite", favorite) putExtra("upName", upName) putExtra("upId", upId) putExtra("upFace", upFace) putExtra("pubTime", pubTime) } ) } } private val playerViewModel: VideoPlayerV3ViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 将当前活动加入队列 synchronized(activityQueue) { val maxVideoPlayerScreens = if (Prefs.showUGCVideoInfo) { 1 } else { Prefs.ugcVideoPlayerHistoryCount.coerceAtLeast(1) } // 清理队列中的无效引用 val iterator = activityQueue.iterator() while (iterator.hasNext()) { val activityRef = iterator.next() val activity = activityRef.get() if (activity == null || activity.isFinishing) { iterator.remove() } } // 添加当前活动到队列 activityQueue.add(WeakReference(this)) // 如果队列超过了最大限制,关闭最早的活动 if (activityQueue.size > maxVideoPlayerScreens) { val oldestActivityRef = activityQueue.removeFirst() val oldestActivity = oldestActivityRef.get() oldestActivity?.runOnUiThread { oldestActivity.finish() } } } initVideoPlayer() //initDanmakuPlayer() getParamsFromIntent() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) setContent { BVTheme( forceDark = true ) { VideoPlayerV3Screen() } } } override fun onDestroy() { super.onDestroy() window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // 显式释放播放器资源,避免依赖 ViewModel.onCleared() 的延迟回调 if (isFinishing) { playerViewModel.releasePlayerResources("onDestroy") } // 当活动被销毁时,从队列中移除该Activity的引用 synchronized(activityQueue) { val iterator = activityQueue.iterator() while (iterator.hasNext()) { val ref = iterator.next() if (ref.get() == this || ref.get() == null) { iterator.remove() } } } // 获取当前内存信息并打印到控制台 val runtime = Runtime.getRuntime() val usedMemory = runtime.totalMemory() - runtime.freeMemory() val maxMemory = runtime.maxMemory() logger.info { "Current memory usage VideoPlayerV3Activity.onDestroy: ${usedMemory / 1024 / 1024} MB / ${maxMemory / 1024 / 1024} MB" } } override fun onPause() { playerViewModel.videoPlayer?.isInBackground = true playerViewModel.videoPlayer?.pause() // 暂停直播弹幕 if (playerViewModel.isLive) { playerViewModel.stopLiveDanmaku() } super.onPause() } override fun onResume() { super.onResume() // 直播从后台恢复时重新获取直播流,避免画面停留在之前的时间点 if (playerViewModel.isLive && playerViewModel.liveRoomId > 0) { logger.info { "Resume live stream for room ${playerViewModel.liveRoomId}" } playerViewModel.loadLiveStreamWithQuality( playerViewModel.liveRoomId, playerViewModel.currentLiveQn ) } } private fun initVideoPlayer() { dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity.Companion.logger.info { "Init video player: ${Prefs.playerType.name}" } val options = VideoPlayerOptions( userAgent = when (Prefs.apiType) { ApiType.Web -> dev.aaa1115910.biliapi.BiliApiConstants.USER_AGENT_WEB ApiType.App -> dev.aaa1115910.biliapi.BiliApiConstants.USER_AGENT_APP }, referer = when (Prefs.apiType) { ApiType.Web -> getString(R.string.video_player_referer) ApiType.App -> null }, enableFfmpegAudioRenderer = Prefs.enableFfmpegAudioRenderer, enableAsyncQueueing = Prefs.enableAsyncQueueing ) val videoPlayer = when (Prefs.playerType) { PlayerType.Media3 -> ExoPlayerFactory().create(this, options) } playerViewModel.videoPlayer = videoPlayer } /*private fun initDanmakuPlayer() { logger.info { "Init danamku player" } runBlocking { playerViewModel.initDanmakuPlayer() } }*/ private fun getParamsFromIntent() { // 检查是否为直播模式 if (intent.getBooleanExtra("isLive", false)) { val roomId = intent.getIntExtra("liveRoomId", 0) val title = intent.getStringExtra("title") ?: "Unknown Title" val upName = intent.getStringExtra("upName") ?: "" val watchedNum = intent.getIntExtra("liveWatchedNum", 0) val upId = intent.getLongExtra("upId", 0L) val upFace = intent.getStringExtra("upFace") ?: "" logger.fInfo { "Launch live parameter: [roomId=$roomId, watchedNum=$watchedNum]" } playerViewModel.apply { this.title = title this.upName = upName this.upId = upId this.upFace = upFace this.isLive = true this.liveRoomId = roomId this.livePopularityText = if (watchedNum > 0) formatPopularity(watchedNum) else "" // 通过 ViewModel 加载直播流(带画质选择,加载成功后自动启动弹幕) loadLiveStreamWithQuality(roomId) } return } if (intent.hasExtra("avid")) { val aid = intent.getLongExtra("avid", 170001) val cid = intent.getLongExtra("cid", 170001) val title = intent.getStringExtra("title") ?: "Unknown Title" val partTitle = intent.getStringExtra("partTitle") ?: "Unknown Part Title" val played = intent.getIntExtra("played", 0) val fromSeason = intent.getBooleanExtra("fromSeason", false) val subType = intent.getIntExtra("subType", 0) val epid = intent.getIntExtra("epid", 0) val seasonId = intent.getIntExtra("seasonId", 0) val isVerticalVideo = intent.getBooleanExtra("isVerticalVideo", false) val proxyArea = ProxyArea.entries[intent.getIntExtra("proxy_area", 0)] val playerIconIdle = intent.getStringExtra("playerIconIdle") ?: "" val playerIconMoving = intent.getStringExtra("playerIconMoving") ?: "" val play = intent.getLongExtra("play", 0) val danmaku = intent.getIntExtra("danmaku", 0) val like = intent.getIntExtra("like", 0) val coin = intent.getIntExtra("coin", 0) val favorite = intent.getIntExtra("favorite", 0) val upName = intent.getStringExtra("upName") ?: "" val upId = intent.getLongExtra("upId", 0) val upFace = intent.getStringExtra("upFace") ?: "" val pubTime = intent.getStringExtra("pubTime") ?: "" dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity.Companion.logger.fInfo { "Launch parameter: [aid=$aid, cid=$cid]" } playerViewModel.apply { // lastPlayed 需要在 loadPlayUrl 之前设置,以便 prepare() 时能正确设置初始跳转位置 this.lastPlayed = played loadPlayUrl( avid = aid, cid = cid, epid = epid.takeIf { it != 0 } ) this.title = title this.partTitle = partTitle this.fromSeason = fromSeason this.subType = subType this.epid = epid this.seasonId = seasonId this.isVerticalVideo = isVerticalVideo this.proxyArea = proxyArea this.playerIconIdle = playerIconIdle this.playerIconMoving = playerIconMoving this.play = play this.danmaku = danmaku this.like = like this.coin = coin this.favorite = favorite this.upName = upName this.upId = upId this.upFace = upFace this.pubTime = pubTime } } else { dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity.Companion.logger.fInfo { "Null launch parameter" } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/Carousel.kt ================================================ package dev.aaa1115910.bv.tv.component import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.CarouselDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.CarouselData import dev.aaa1115910.bv.util.focusedBorder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable fun PgcCarousel( modifier: Modifier = Modifier, data: List, onClick: (CarouselData.CarouselItem) -> Unit ) { CarouselContent( modifier = modifier, data = data, onClick = onClick ) } @Composable fun UgcCarousel( modifier: Modifier = Modifier, data: List, onClick: (CarouselData.CarouselItem) -> Unit ) { CarouselContent( modifier = modifier, data = data, onClick = onClick ) } @Composable fun CarouselContent( modifier: Modifier = Modifier, data: List, onClick: (CarouselData.CarouselItem) -> Unit ) { Carousel( itemCount = data.size, modifier = modifier .height(240.dp) .clip(MaterialTheme.shapes.medium) .focusedBorder(MaterialTheme.shapes.medium), onClick = { itemIndex -> onClick(data[itemIndex]) } ) { itemIndex -> CarouselCard( data = data[itemIndex] ) } } @Composable fun CarouselCard( modifier: Modifier = Modifier, data: CarouselData.CarouselItem ) { AsyncImage( modifier = modifier.fillMaxWidth(), model = data.cover, contentDescription = null, contentScale = ContentScale.Crop, alignment = Alignment.TopCenter ) } @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun Carousel( itemCount: Int, modifier: Modifier = Modifier, autoScrollInterval: Long = CarouselDefaults.TimeToDisplayItemMillis, contentTransformStartToEnd: ContentTransform = fadeIn(tween(1000)) .togetherWith(fadeOut(tween(1000))), contentTransformEndToStart: ContentTransform = fadeIn(tween(1000)) .togetherWith(fadeOut(tween(1000))), onClick: (index: Int) -> Unit, content: @Composable AnimatedContentScope.(index: Int) -> Unit ) { var hasFocus by remember { mutableStateOf(false) } var isMovingBackward by remember { mutableStateOf(false) } var currentIndex by remember { mutableIntStateOf(0) } LaunchedEffect(currentIndex, itemCount) { while (true) { delay(autoScrollInterval) if (itemCount == 0 || hasFocus) continue isMovingBackward = false currentIndex = (currentIndex + 1) % itemCount } } Box( modifier = modifier .onFocusChanged { focusState -> hasFocus = focusState.isFocused } .clickable { onClick(currentIndex) } .onKeyEvent { when { itemCount == 0 -> false it.type == KeyEventType.KeyUp -> false it.key == Key.DirectionLeft -> { isMovingBackward = true currentIndex = (currentIndex - 1 + itemCount) % itemCount true } it.key == Key.DirectionRight -> { isMovingBackward = false currentIndex = (currentIndex + 1) % itemCount true } else -> false } } ) { AnimatedContent( targetState = currentIndex, transitionSpec = { if (isMovingBackward) { contentTransformEndToStart } else { contentTransformStartToEnd } }, label = "CarouselAnimation" ) { activeItemIndex -> if (itemCount > 0) content(activeItemIndex) } CarouselDefaults.IndicatorRow( itemCount = itemCount, activeItemIndex = currentIndex, modifier = Modifier .align(Alignment.BottomEnd) .padding(16.dp), ) } } @Preview @Composable private fun CarouselPreview() { val colors = remember { mutableStateListOf() } val scope = rememberCoroutineScope() LaunchedEffect(Unit) { scope.launch(Dispatchers.IO) { delay(8000) colors.addAll( listOf( Color.Red, Color.Yellow, Color.Green, Color.Blue, Color.Cyan, Color.Magenta, Color.Gray, ) ) } } Column { Button(onClick = {}) { Text(text = "button") } Row { Button(onClick = {}) { Text(text = "button") } Carousel( itemCount = colors.size, modifier = Modifier .fillMaxWidth() .height(200.dp) .clip(MaterialTheme.shapes.medium) .focusedBorder(MaterialTheme.shapes.medium), onClick = { } ) { Box( modifier = Modifier .fillMaxWidth() .fillMaxHeight() .background(color = colors[it]) ) {} } } Button(onClick = {}) { Text(text = "button") } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/CommentItem.kt ================================================ package dev.aaa1115910.bv.tv.component 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.rounded.ThumbUp import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.EmoteSize import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.focusedBorder /** * 评论列表项组件 * * @param comment 评论数据 * @param modifier 修饰符 * @param onClick 点击回调 * @param onLongClick 长按回调 */ @Composable fun CommentItem( comment: Comment, modifier: Modifier = Modifier, onClick: () -> Unit = {}, onLongClick: () -> Unit = {} ) { Surface( modifier = modifier .fillMaxWidth() .focusedBorder(MaterialTheme.shapes.small), onClick = onClick, onLongClick = onLongClick, colors = ClickableSurfaceDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), pressedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) ), scale = ClickableSurfaceDefaults.scale( focusedScale = 1f, pressedScale = 1f ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.small) ) { Column( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { // 主评论 CommentMainContent(comment = comment) } } } /** * 主评论内容 */ @Composable private fun CommentMainContent( comment: Comment ) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top ) { // 用户头像 AsyncImage( modifier = Modifier .size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surface), model = comment.member.avatar, contentDescription = null, ) // 评论内容 Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp) ) { // 用户名 Text( text = comment.member.name, style = MaterialTheme.typography.titleSmall, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis ) // 评论内容(支持表情) CommentContent( content = comment.content, emotes = comment.emotes, modifier = Modifier.padding(top = 4.dp) ) // 评论图片 if (comment.pictures.isNotEmpty()) { CommentPictures( pictures = comment.pictures, modifier = Modifier.padding(top = 4.dp) ) } // 底部信息:时间和点赞数 Row( modifier = Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically ) { // 时间 Text( text = comment.timeDesc, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) // 点赞数 Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier.size(14.dp), imageVector = androidx.compose.material.icons.Icons.Rounded.ThumbUp, contentDescription = null, tint = Color.White.copy(alpha = 0.5f) ) Text( text = formatLikeCount(comment.like), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) } // 回复数 if (comment.repliesCount > 0) { Text( text = "${comment.repliesCount} 回复", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) } } } } } /** * 评论内容组件,支持表情显示(富文本) */ @Composable fun CommentContent( content: List, emotes: List, modifier: Modifier = Modifier, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip ) { val emoteNameList = emotes.map { it.text } val inlineContentMap = emotes.associateWith { emote -> InlineTextContent( Placeholder( width = emote.size.fontSize.sp, height = emote.size.fontSize.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter ) ) { AsyncImage( model = emote.url, contentDescription = null ) } }.mapKeys { it.key.text } Text( modifier = modifier, text = buildAnnotatedString { content.forEach { text -> if (emoteNameList.contains(text)) { appendInlineContent(text) } else { append(text) } } }, inlineContent = inlineContentMap, style = MaterialTheme.typography.bodyMedium, color = Color.White, maxLines = maxLines, overflow = overflow ) } /** * 格式化点赞数 */ private fun formatLikeCount(count: Long): String { return when { count >= 10000 -> "${count / 10000}万" else -> count.toString() } } /** * 生成 B 站图片缩略图 URL * * 参数格式:@{w}w_{h}h_{flags}.webp * dpr 固定为 2,格式固定 webp */ private fun buildThumbnailUrl( url: String, w: Int = 0, h: Int = 0, crop: Boolean = false, progressive: Boolean = true, dpr: Int = 2 ): String { val baseUrl = url.split("@")[0].replace("//pre-", "//") val parts = mutableListOf() if (w > 0) parts.add("${w * dpr}w") if (h > 0) parts.add("${h * dpr}h") if (crop) parts.add("1c") if (progressive) parts.add("1s") return if (parts.isNotEmpty()) "$baseUrl@${parts.joinToString("_")}.webp" else baseUrl } private data class CommentPictureItem( val width: Int, val height: Int, val thumbnailUrl: String, val original: Picture ) /** * 计算评论图片的展示尺寸和缩略图 URL * * 与 bilibili PC 评论区 bili-comment-pictures-renderer 逻辑一致: * - 单图:横图框 240×135,竖图框 135×180,超长图裁剪 * - 多图:统一 88×88 裁剪 */ private fun calculatePictureItems(pictures: List): List { val isSingle = pictures.size == 1 val multipleSize = 88 return pictures.map { pic -> val imgW = pic.width val imgH = pic.height val isHorizontal = imgW > imgH val ratio = if (isHorizontal) imgW.toFloat() / imgH else imgH.toFloat() / imgW val isLong = kotlin.math.floor(ratio.toDouble()).toInt() >= 3 if (isSingle) { val singleHorizontal = 240 to 135 val singleVertical = 135 to 180 val targetRatio = imgW.toFloat() / imgH var w: Int var h: Int if (isLong) { val frame = if (isHorizontal) singleHorizontal else singleVertical w = frame.first h = frame.second } else if (!isHorizontal && imgW > singleVertical.first && imgH > singleVertical.second) { w = singleVertical.first h = singleVertical.second } else if (isHorizontal) { val frameRatio = singleHorizontal.first.toFloat() / singleHorizontal.second if (targetRatio > frameRatio) { w = singleHorizontal.first h = (w / targetRatio).toInt() } else { h = singleHorizontal.second w = (h * targetRatio).toInt() } if (w > imgW) { w = imgW; h = imgH } } else { val frameRatio = singleVertical.first.toFloat() / singleVertical.second if (targetRatio > frameRatio) { w = singleVertical.first h = (w / targetRatio).toInt() } else { h = singleVertical.second w = (h * targetRatio).toInt() } if (w > imgW) { w = imgW; h = imgH } } val thumbUrl = buildThumbnailUrl( pic.url, w = w, h = h, crop = isLong, progressive = true ) CommentPictureItem(width = w, height = h, thumbnailUrl = thumbUrl, original = pic) } else { val thumbUrl = buildThumbnailUrl( pic.url, w = multipleSize, h = multipleSize, crop = true, progressive = true ) CommentPictureItem( width = multipleSize, height = multipleSize, thumbnailUrl = thumbUrl, original = pic ) } } } /** * 评论图片组件 * * - 单图:保持比例,宽度不超过内容区 * - 多图:一行最多 2 张,正方形裁剪 */ @Composable fun CommentPictures( pictures: List, modifier: Modifier = Modifier ) { val validPictures = remember(pictures) { pictures.filter { it.url.isNotBlank() && it.width > 0 && it.height > 0 } } if (validPictures.isEmpty()) return val items = remember(validPictures) { calculatePictureItems(validPictures) } val isSingle = validPictures.size == 1 if (isSingle) { val item = items.first() val ratio = if (item.height > 0) item.width.toFloat() / item.height else 1f AsyncImage( model = item.thumbnailUrl, contentDescription = null, modifier = modifier .widthIn(max = item.width.dp) .aspectRatio(ratio) .clip(RoundedCornerShape(6.dp)), contentScale = ContentScale.Crop ) } else { val rows = items.chunked(2) Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp) ) { rows.forEach { rowItems -> Row( horizontalArrangement = Arrangement.spacedBy(6.dp) ) { rowItems.forEach { item -> AsyncImage( model = item.thumbnailUrl, contentDescription = null, modifier = Modifier .size(item.width.dp) .clip(RoundedCornerShape(6.dp)), contentScale = ContentScale.Crop ) } } } } } } @Preview(uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Composable private fun CommentItemPreview() { BVTheme { CommentItem( comment = Comment( rpid = 123456, mid = 789, oid = 12345, type = 1, parent = 0, content = listOf("这是一条测试评论", "[2333]", "后面还有内容"), member = Comment.Member( mid = 789, avatar = "", name = "测试用户" ), timeDesc = "2小时前", emotes = listOf( Comment.Emote( text = "[2333]", url = "https://i0.hdslb.com/bfs/emote/4352e2396c13e4150786d48e464d517174845b9c.png", size = EmoteSize.Small ) ), pictures = emptyList(), replies = emptyList(), repliesCount = 5, like = 12345L ) ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/CommentPanel.kt ================================================ package dev.aaa1115910.bv.tv.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandHorizontally import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.animateScrollBy 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.CommentPage import dev.aaa1115910.biliapi.entity.reply.CommentSort import dev.aaa1115910.biliapi.entity.video.season.Episode import dev.aaa1115910.biliapi.entity.video.season.Section import dev.aaa1115910.biliapi.repositories.CommentRepository import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.isDpadDown import dev.aaa1115910.bv.util.isDpadLeft import dev.aaa1115910.bv.util.isKeyDown import dev.aaa1115910.bv.util.onBackPressed import dev.aaa1115910.bv.util.requestFocus import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.getKoin import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.resizedImageUrl /** * 评论浮层组件 * * @param show 是否显示浮层 * @param oid 视频 aid * @param onHide 关闭浮层回调 * @param episodes 正片剧集列表(用于选集切换) * @param sections 章节选集列表(用于选集切换) * @param initialEpisodeId 初始选中的剧集ID * @param onEpisodeChange 剧集切换回调 */ @Composable fun CommentPanel( show: Boolean, oid: Long, onHide: () -> Unit, episodes: List = emptyList(), sections: List
= emptyList(), initialEpisodeId: Int = -1, onEpisodeChange: ((Episode) -> Unit)? = null ) { val commentRepository: CommentRepository = getKoin().get() val scope = rememberCoroutineScope() val listState = rememberLazyListState() val focusRequester = remember { FocusRequester() } val sidebarFocusRequester = remember { FocusRequester() } val density = LocalDensity.current val comments = remember { mutableStateListOf() } var loading by remember { mutableStateOf(false) } var currentPage by remember { mutableStateOf(CommentPage()) } var hasNext by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } // 子评论浮窗状态 var showSubCommentPanel by remember { mutableStateOf(false) } var selectedRootComment by remember { mutableStateOf(null) } var hasRequestedFocus by remember { mutableStateOf(false) } var wasSubCommentPanelShown by remember { mutableStateOf(false) } var selectedCommentIndex by remember { mutableStateOf(0) } var focusedCommentIndex by remember { mutableStateOf(0) } // 全屏图片查看器状态 var showImageViewer by remember { mutableStateOf(false) } var imageViewerPictures by remember { mutableStateOf>(emptyList()) } // 选集相关状态 var currentEpisode by remember { mutableStateOf(null) } var focusOnSidebar by remember { mutableStateOf(false) } var sidebarFocusRequestToken by remember { mutableIntStateOf(0) } var pendingFocusToComments by remember { mutableStateOf(false) } // 合并所有剧集(正片 + 章节) val allEpisodeItems by remember(episodes, sections) { derivedStateOf { buildList { var idx = 0 // 添加正片剧集 if (episodes.isNotEmpty()) { episodes.forEach { ep -> // 过滤掉 aid 为 0 的剧集 if (ep.aid > 0) { add(EpisodeItem(ep, "正片", idx++)) } } } // 添加章节剧集 sections.forEach { section -> section.episodes.forEach { ep -> // 过滤掉 aid 为 0 的剧集 if (ep.aid > 0) { add(EpisodeItem(ep, section.title, idx++)) } } } } } } // 是否显示侧边栏(多于1集时显示) val showSidebar by remember(allEpisodeItems) { derivedStateOf { allEpisodeItems.size > 1 } } fun requestFocusToCurrentEpisode() { if (!showSidebar) return focusOnSidebar = true sidebarFocusRequestToken++ } // 初始化当前选中的剧集 LaunchedEffect(allEpisodeItems, initialEpisodeId) { if (currentEpisode == null && allEpisodeItems.isNotEmpty()) { currentEpisode = if (initialEpisodeId != -1) { allEpisodeItems.find { it.episode.id == initialEpisodeId }?.episode } else { allEpisodeItems.firstOrNull()?.episode } } } // 获取当前要加载评论的 aid val currentOid by remember(currentEpisode, oid) { derivedStateOf { currentEpisode?.aid ?: oid } } // 判断是否滚动到底部 val isAtBottom by remember { derivedStateOf { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == comments.size - 1 } } // 加载评论 val loadComments: (reset: Boolean) -> Unit = { reset -> scope.launch { if (loading) return@launch loading = true error = null try { val page = if (reset) CommentPage() else currentPage val data = commentRepository.getComments( id = currentOid, type = 1L, // 视频评论 sort = CommentSort.Hot, page = page, preferApiType = Prefs.apiType ) if (reset) { comments.clear() comments.addAll(data.comments) } else { comments.addAll(data.comments) } currentPage = data.nextPage hasNext = data.hasNext } catch (e: Exception) { error = e.message ?: "加载失败" } finally { loading = false } } } // 显示时加载评论 LaunchedEffect(show, currentOid) { if (show && comments.isEmpty()) { loadComments(true) } if (!show) { hasRequestedFocus = false wasSubCommentPanelShown = false // 重置边栏焦点状态,确保下次打开时焦点在评论列表 focusOnSidebar = false sidebarFocusRequestToken = 0 pendingFocusToComments = false } } // 显示后请求焦点(初次显示时或子评论浮窗关闭后) LaunchedEffect(show, showSubCommentPanel, comments.isNotEmpty(), loading) { if (show && !showSubCommentPanel) { // 子评论浮窗刚关闭,需要恢复焦点到之前点击的评论 if (wasSubCommentPanelShown) { delay(300) // 等待动画完成 listState.scrollToItem(selectedCommentIndex) delay(100) focusRequester.requestFocus(scope) wasSubCommentPanelShown = false } // 切换剧集后评论加载完成,请求焦点到评论列表 else if (pendingFocusToComments) { delay(100) // 等待渲染完成 if (!loading) { delay(100) // 等待渲染完成 if (comments.isNotEmpty() || !showSidebar || error != null) { focusRequester.requestFocus(scope) } else { requestFocusToCurrentEpisode() } pendingFocusToComments = false } } // 初次显示父评论浮窗,请求焦点 else if (!hasRequestedFocus) { delay(200) // 等待请求完成 if(!loading){ delay(200) // 等待动画完成 if (comments.isNotEmpty() || !showSidebar || error != null) { focusRequester.requestFocus(scope) } else { requestFocusToCurrentEpisode() } hasRequestedFocus = true } } } // 记录子评论浮窗显示状态 if (showSubCommentPanel) { wasSubCommentPanelShown = true } } // 懒加载:滚动到底部时加载更多 LaunchedEffect(isAtBottom, hasNext, loading) { if (isAtBottom && hasNext && !loading && comments.isNotEmpty()) { loadComments(false) } } Box( modifier = Modifier .fillMaxSize() .clickable(onClick = onHide), contentAlignment = Alignment.CenterEnd ) { AnimatedVisibility( visible = show && !showSubCommentPanel, enter = expandHorizontally(expandFrom = Alignment.End), exit = shrinkHorizontally(shrinkTowards = Alignment.End) ) { Surface( modifier = Modifier .fillMaxHeight() .padding(horizontal = 16.dp, vertical = 16.dp) .widthIn( min = if (showSidebar) 620.dp else 320.dp, max = if (showSidebar) 720.dp else 420.dp ) .fillMaxWidth(if (showSidebar) 0.5f else 0.3f) .clickable(enabled = true, onClick = {}) // 阻止点击穿透 .onBackPressed { if (focusOnSidebar && showSidebar) { // 在边栏时按返回键关闭评论面板 onHide() } else if (showSidebar) { // 在评论列表时按返回键切换到边栏 requestFocusToCurrentEpisode() } else { // 没有边栏时直接关闭 onHide() } }, colors = SurfaceDefaults.colors( containerColor = Color.Black.copy(alpha = 0.85f) ), shape = MaterialTheme.shapes.large ) { Row( modifier = Modifier .fillMaxSize() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // 左侧边栏 - 仅在有多个剧集时显示 if (showSidebar) { EpisodeSidebar( episodes = allEpisodeItems, currentEpisode = currentEpisode, onEpisodeSelected = { episodeItem -> currentEpisode = episodeItem.episode onEpisodeChange?.invoke(episodeItem.episode) comments.clear() loadComments(true) // 切换剧集后将焦点移回评论列表 focusOnSidebar = false // 标记需要在评论加载完成后请求焦点 pendingFocusToComments = true }, modifier = Modifier .width(220.dp) .fillMaxHeight(), focusRequester = sidebarFocusRequester, onFocusMoved = { // 焦点返回评论列表 focusOnSidebar = false scope.launch { focusRequester.requestFocus(scope) } }, focusRequestToken = sidebarFocusRequestToken ) } // 右侧评论列表区域 Column( modifier = Modifier .weight(1f) .fillMaxHeight() .onFocusChanged { focusState -> if (focusState.hasFocus && focusOnSidebar) { focusOnSidebar = false } }, verticalArrangement = Arrangement.spacedBy(8.dp) ) { // 标题栏 Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = "评论", style = MaterialTheme.typography.titleLarge, color = Color.White ) // 操作提示 Text( text = if (showSidebar) "左键返回顶部,返回键切换剧集" else "左键返回顶部", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) // 显示当前剧集 if (currentEpisode != null && showSidebar) { Text( text = generateEpisodeTitle(currentEpisode, ""), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.7f) ) } } Text( text = if (comments.isNotEmpty()) "${comments.size} 条" else "", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.7f) ) } // 评论列表 if (error != null) { Box( modifier = Modifier .weight(1f) .fillMaxWidth() .focusRequester(focusRequester) .focusable(), contentAlignment = Alignment.TopStart ) { Text( text = error ?: "加载失败", color = Color.Red, modifier = Modifier .align(Alignment.Center) .padding(16.dp) ) } } else if (comments.isEmpty() && !loading) { Box( modifier = Modifier .weight(1f) .fillMaxWidth() .focusRequester(focusRequester) .focusable(), contentAlignment = Alignment.TopStart ) { Text( text = "暂无评论", color = Color.White.copy(alpha = 0.5f), modifier = Modifier .align(Alignment.Center) .padding(16.dp) ) } } else { LazyColumn( modifier = Modifier .weight(1f) .fillMaxWidth() .focusRequester(focusRequester) .onPreviewKeyEvent { event -> when { // 左键:第一条时转移焦点到左侧边栏,否则返回顶部 event.isKeyDown() && event.isDpadLeft() -> { if (focusedCommentIndex == 0 && showSidebar) { requestFocusToCurrentEpisode() } else { scope.launch { listState.scrollToItem(0) delay(100) focusRequester.requestFocus(scope) } } true } // 下键:逐步滚动,在列表末尾时阻止焦点移出 event.isKeyDown() && event.isDpadDown() -> { val layoutInfo = listState.layoutInfo // 已聚焦到最后一条评论时,阻止焦点移出列表 if (focusedCommentIndex >= comments.size - 1 && comments.isNotEmpty()) { true } else { val currentItemInfo = layoutInfo.visibleItemsInfo .firstOrNull { it.index == focusedCommentIndex } if (currentItemInfo != null) { val viewportEnd = layoutInfo.viewportEndOffset val itemBottom = currentItemInfo.offset + currentItemInfo.size // 如果评论底部不可见,逐步滚动 if (itemBottom > viewportEnd) { scope.launch { // 每次滚动约 100dp val scrollAmount = with(density) { 100.dp.toPx() } listState.animateScrollBy(scrollAmount) } true // 拦截事件,不允许焦点转移 } else { false // 评论已完全可见,允许焦点转移 } } else { false } } } else -> false } }, state = listState, verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = comments, key = { index, it -> "$index-comment-${it.rpid}" } ) { index, comment -> CommentItem( comment = comment, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> if (focusState.hasFocus) { focusedCommentIndex = index } }, onClick = { // 只有有子评论时才能点击打开子评论浮窗 if (comment.repliesCount > 0) { selectedCommentIndex = index selectedRootComment = comment showSubCommentPanel = true } }, onLongClick = { if (comment.pictures.isNotEmpty()) { imageViewerPictures = comment.pictures showImageViewer = true } } ) } // 加载状态 if (loading) { item { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.Center ) { LoadingTip() } } } // 没有更多了 if (!hasNext && comments.isNotEmpty()) { item { Text( modifier = Modifier .fillMaxWidth() .padding(16.dp), text = "没有更多评论了", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) } } } } // 底部提示 Spacer(modifier = Modifier.height(8.dp)) } // Column (右侧评论列表区域) 结束 } // Row 结束 } // Surface 结束 } // AnimatedVisibility 结束 } // Box 结束 // 子评论浮窗 if (selectedRootComment != null) { SubCommentPanel( show = showSubCommentPanel, oid = currentOid, rootId = selectedRootComment!!.rpid, rootComment = selectedRootComment!!, onHide = { showSubCommentPanel = false selectedRootComment = null } ) } // 全屏图片查看器 if (showImageViewer && imageViewerPictures.isNotEmpty()) { FullscreenImageViewer( pictures = imageViewerPictures, onDismiss = { showImageViewer = false imageViewerPictures = emptyList() } ) } } /** * 选集侧边栏项数据类 */ private data class EpisodeItem( val episode: Episode, val sectionTitle: String, val index: Int ) /** * 生成剧集标题 */ private fun generateEpisodeTitle( episode: Episode?, sectionTitle: String ): String { if (episode == null) return "" return if (episode.longTitle.isNotEmpty()) { runCatching { "第 ${episode.title.toInt()} 集 " }.getOrDefault("") + episode.longTitle } else if (sectionTitle == "正片") { runCatching { "第 ${episode.title.toInt()} 集" }.getOrDefault(episode.title) } else { episode.title } } /** * 选集侧边栏组件 */ @Composable private fun EpisodeSidebar( episodes: List, currentEpisode: Episode?, onEpisodeSelected: (EpisodeItem) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester, onFocusMoved: () -> Unit = {}, focusRequestToken: Int = 0 ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() val context = LocalContext.current // 为每个剧集项创建独立的 FocusRequester val itemFocusRequesters = remember(episodes.size) { List(episodes.size) { FocusRequester() } } // 计算在 LazyColumn 中的实际索引(考虑章节标题) fun calculateLazyColumnIndex(episodes: List, episodeIndex: Int): Int { if (episodeIndex < 0 || episodes.isEmpty()) return 0 var actualIndex = 0 var lastSectionTitle = "" // 遍历到目标索引之前的所有项 for (i in 0 until episodeIndex) { if (episodes[i].sectionTitle != lastSectionTitle) { lastSectionTitle = episodes[i].sectionTitle actualIndex++ // 章节标题占一个索引 } actualIndex++ // 剧集项占一个索引 } // 检查目标项本身是否有新章节标题 if (episodes[episodeIndex].sectionTitle != lastSectionTitle) { actualIndex++ // 目标项的章节标题 } return actualIndex // 返回剧集项的正确索引 } // 初始化时滚动到当前选中的剧集(只滚动,不请求焦点) LaunchedEffect(currentEpisode, episodes) { val index = episodes.indexOfFirst { it.episode.id == currentEpisode?.id } if (index >= 0) { val actualIndex = calculateLazyColumnIndex(episodes, index) listState.scrollToItem(maxOf(0, actualIndex - 2)) } } // 当收到焦点请求时,滚动并请求焦点到当前剧集 LaunchedEffect(focusRequestToken) { if (focusRequestToken > 0) { delay(50) // 短暂等待确保布局就绪 val index = episodes.indexOfFirst { it.episode.id == currentEpisode?.id } if (index >= 0) { val actualIndex = calculateLazyColumnIndex(episodes, index) listState.scrollToItem(maxOf(0, actualIndex - 2)) delay(50) // 直接请求焦点到当前选中的剧集项 itemFocusRequesters.getOrNull(index)?.requestFocus() } else { // 没有找到当前剧集,焦点到第一个剧集 itemFocusRequesters.firstOrNull()?.requestFocus() } } } LazyColumn( modifier = modifier.focusRequester(focusRequester), state = listState, verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = PaddingValues(vertical = 8.dp) ) { // 按章节分组显示 var lastSectionTitle = "" episodes.forEachIndexed { index, item -> // 章节标题 if (item.sectionTitle != lastSectionTitle) { lastSectionTitle = item.sectionTitle item { Text( text = item.sectionTitle, style = MaterialTheme.typography.titleSmall, color = Color.White.copy(alpha = 0.7f), modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) ) } } // 剧集按钮 item { val isSelected = item.episode.id == currentEpisode?.id EpisodeSidebarItem( episode = item.episode, sectionTitle = item.sectionTitle, isSelected = isSelected, onClick = { onEpisodeSelected(item) }, onBackKeyPressed = onFocusMoved, focusRequester = itemFocusRequesters[index] ) } } } } /** * 选集侧边栏单项组件 */ @Composable private fun EpisodeSidebarItem( episode: Episode, sectionTitle: String, isSelected: Boolean, onClick: () -> Unit, onBackKeyPressed: () -> Unit = {}, focusRequester: FocusRequester = remember { FocusRequester() } ) { val borderColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) else null val context = LocalContext.current Surface( modifier = Modifier.focusRequester(focusRequester), onClick = onClick, colors = ClickableSurfaceDefaults.colors( containerColor = if (isSelected) { Color.White.copy(alpha = 0.15f) } else { Color.Transparent }, focusedContainerColor = if (isSelected) { Color.White.copy(alpha = 0.15f) } else { Color.Transparent } ), scale = ClickableSurfaceDefaults.scale( focusedScale = 1f ), border = ClickableSurfaceDefaults.border( border = borderColor?.let { Border(border = BorderStroke(width = 2.dp, color = it)) } ?: Border.None, focusedBorder = Border( border = BorderStroke(width = 2.dp, color = MaterialTheme.colorScheme.border), shape = MaterialTheme.shapes.small ) ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.small) ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // 剧集封面缩略图 AsyncImage( modifier = Modifier .size(48.dp) .clip(MaterialTheme.shapes.extraSmall), model = episode.cover.resizedImageUrl(ImageSize.UgcEpisodeCover), contentDescription = null, contentScale = ContentScale.Crop ) // 剧集标题 Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = generateEpisodeTitle(episode, sectionTitle), style = MaterialTheme.typography.bodySmall, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis ) if (episode.longTitle.isNotEmpty()) { Text( text = episode.longTitle, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/DescriptionPanel.kt ================================================ package dev.aaa1115910.bv.tv.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandHorizontally import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.animateScrollBy 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.SuggestionChip import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.video.Tag import dev.aaa1115910.bv.util.isDpadDown import dev.aaa1115910.bv.util.isDpadUp import dev.aaa1115910.bv.util.isKeyDown import dev.aaa1115910.bv.util.onBackPressed import dev.aaa1115910.bv.util.requestFocus import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * 视频简介浮层组件 * * @param show 是否显示浮层 * @param description 视频简介 * @param tags 视频标签列表 * @param onHide 关闭浮层回调 * @param onClickTag 标签点击回调 */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun DescriptionPanel( show: Boolean, description: String, tags: List = emptyList(), onHide: () -> Unit, onClickTag: (Tag) -> Unit = {} ) { val scope = rememberCoroutineScope() val contentFocusRequester = remember { FocusRequester() } val firstTagFocusRequester = remember { FocusRequester() } val scrollState = rememberScrollState() val density = LocalDensity.current var hasRequestedFocus by remember { mutableStateOf(false) } // 显示时请求焦点:优先聚焦第一个标签,没有标签则聚焦简介内容 LaunchedEffect(show) { if (show) { delay(300) if (tags.isNotEmpty()) { firstTagFocusRequester.requestFocus(scope) } else { contentFocusRequester.requestFocus(scope) } hasRequestedFocus = true } else { hasRequestedFocus = false } } Box( modifier = Modifier .fillMaxSize() .clickable(onClick = onHide), contentAlignment = Alignment.CenterEnd ) { AnimatedVisibility( visible = show, enter = expandHorizontally(expandFrom = Alignment.End), exit = shrinkHorizontally(shrinkTowards = Alignment.End) ) { Surface( modifier = Modifier .fillMaxHeight() .padding(horizontal = 16.dp, vertical = 16.dp) .widthIn(min = 300.dp, max = 400.dp) .fillMaxWidth(0.3f) .clickable(enabled = true, onClick = {}) // 阻止点击穿透 .onBackPressed { onHide() }, colors = SurfaceDefaults.colors( containerColor = Color.Black.copy(alpha = 0.85f) ), shape = MaterialTheme.shapes.large ) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { // 标题栏(固定不滚动) Text( text = "视频简介", style = MaterialTheme.typography.titleLarge, color = Color.White, modifier = Modifier.padding(bottom = 4.dp) ) // 标签行(固定不滚动,可横向滚动) if (tags.isNotEmpty()) { LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { itemsIndexed( items = tags, key = { index, tag -> "$index-desc-tag-${tag.name}" } ) { index, tag -> SuggestionChip( modifier = if (index == 0) Modifier.focusRequester(firstTagFocusRequester) else Modifier, onClick = { onClickTag(tag) } ) { Text(text = tag.name) } } } } // 简介内容(可滚动) Box( modifier = Modifier .weight(1f) .fillMaxWidth() .focusRequester(contentFocusRequester) .focusable() .onPreviewKeyEvent { event -> when { event.isKeyDown() && event.isDpadDown() -> { scope.launch { val scrollAmount = with(density) { 100.dp.toPx() } scrollState.animateScrollBy(scrollAmount) } true } event.isKeyDown() && event.isDpadUp() -> { scope.launch { val scrollAmount = with(density) { 100.dp.toPx() } scrollState.animateScrollBy(-scrollAmount) } true } else -> false } } ) { Text( modifier = Modifier .verticalScroll(scrollState) .padding(top = 4.dp), text = description, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.9f) ) } } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/FullscreenImageViewer.kt ================================================ package dev.aaa1115910.bv.tv.component 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import dev.aaa1115910.biliapi.entity.Picture import dev.aaa1115910.bv.util.isDpadLeft import dev.aaa1115910.bv.util.isDpadRight import dev.aaa1115910.bv.util.isKeyDown /** * 全屏图片查看器 * * @param pictures 图片列表 * @param initialIndex 初始显示的图片索引 * @param onDismiss 关闭回调 */ @Composable fun FullscreenImageViewer( pictures: List, initialIndex: Int = 0, onDismiss: () -> Unit ) { var currentIndex by remember { mutableIntStateOf(initialIndex) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } Dialog( onDismissRequest = onDismiss, properties = DialogProperties( usePlatformDefaultWidth = false, dismissOnBackPress = true ) ) { Surface( modifier = Modifier .fillMaxSize() .focusRequester(focusRequester) .onPreviewKeyEvent { event -> if (!event.isKeyDown()) return@onPreviewKeyEvent false when { event.isDpadLeft() -> { if (currentIndex > 0) currentIndex-- true } event.isDpadRight() -> { if (currentIndex < pictures.size - 1) currentIndex++ true } event.key == Key.Back -> { onDismiss() true } else -> false } }, onClick = { /* 消费点击事件 */ }, colors = ClickableSurfaceDefaults.colors( containerColor = Color.Black, focusedContainerColor = Color.Black, pressedContainerColor = Color.Black ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1f, pressedScale = 1f), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(0.dp)) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { // 图片 val painter = rememberAsyncImagePainter(model = pictures[currentIndex].url) val painterState = painter.state if (painterState is AsyncImagePainter.State.Loading) { CircularProgressIndicator( modifier = Modifier.size(36.dp), color = Color.White ) } AsyncImage( model = pictures[currentIndex].url, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit ) // 页码指示器 Box( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 32.dp) .background( Color.Black.copy(alpha = 0.6f), RoundedCornerShape(16.dp) ) .padding(horizontal = 16.dp, vertical = 8.dp) ) { Text( text = "${currentIndex + 1}/${pictures.size}", style = MaterialTheme.typography.bodyMedium, color = Color.White ) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/GeetestTvVerifyDialog.kt ================================================ package dev.aaa1115910.bv.tv.component import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.DashPathEffect import android.graphics.Paint import android.os.SystemClock import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebView import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.focusable 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.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import io.github.oshai.kotlinlogging.KotlinLogging private val logger = KotlinLogging.logger("GeetestTvVerify") /** * Geetest 验证结果 */ data class GeetestTvResult( val challenge: String, val validate: String, val seccode: String, ) /** * TV 端 Geetest 风控验证弹窗 * * 在 WebView 中加载极验 JS SDK,上层覆盖十字光标, * 通过遥控器方向键移动光标、确认键触发点击 → 将 touch 事件注入 WebView。 * * 交互方式: * - 方向键 (D-Pad) 移动十字光标,长按/连按加速 * - 确认键 (Center/Enter) 在当前光标位置点击 WebView * - 返回键 (Back) 取消验证 * * @param gt Geetest gt 参数 * @param challenge Geetest challenge 参数 * @param onResult 验证成功后回调 * @param onDismiss 用户取消/关闭时回调 */ @Composable fun GeetestTvVerifyDialog( gt: String, challenge: String, onResult: (GeetestTvResult) -> Unit, onDismiss: () -> Unit, ) { Dialog( onDismissRequest = onDismiss, properties = DialogProperties( usePlatformDefaultWidth = false, dismissOnBackPress = false, ) ) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.75f)), contentAlignment = Alignment.Center, ) { GeetestTvVerifyContent( gt = gt, challenge = challenge, onResult = onResult, onDismiss = onDismiss, ) } } } @SuppressLint("SetJavaScriptEnabled") @Composable private fun GeetestTvVerifyContent( gt: String, challenge: String, onResult: (GeetestTvResult) -> Unit, onDismiss: () -> Unit, ) { val density = LocalDensity.current val focusRequester = remember { FocusRequester() } // WebView 容器实际像素尺寸 var containerWidthPx by remember { mutableFloatStateOf(0f) } var containerHeightPx by remember { mutableFloatStateOf(0f) } // 光标位置 (像素坐标,相对于 WebView 容器) var cursorX by remember { mutableFloatStateOf(0f) } var cursorY by remember { mutableFloatStateOf(0f) } var cursorInitialized by remember { mutableStateOf(false) } // 状态提示 var statusText by remember { mutableStateOf("正在加载验证码…") } // WebView 引用 var webViewRef by remember { mutableStateOf(null) } // 光标覆盖层引用 var overlayRef by remember { mutableStateOf(null) } // 移动步长 val baseStep = with(density) { 6.dp.toPx() } val fastStep = with(density) { 20.dp.toPx() } // 初始化光标到中心 LaunchedEffect(containerWidthPx, containerHeightPx) { if (containerWidthPx > 0 && containerHeightPx > 0 && !cursorInitialized) { cursorX = containerWidthPx / 2f cursorY = containerHeightPx / 2f cursorInitialized = true } } LaunchedEffect(Unit) { focusRequester.requestFocus() } // 清理 WebView DisposableEffect(Unit) { onDispose { webViewRef?.let { wv -> runCatching { wv.removeJavascriptInterface("Android") wv.stopLoading() wv.destroy() } } } } fun clampCursor() { cursorX = cursorX.coerceIn(0f, containerWidthPx) cursorY = cursorY.coerceIn(0f, containerHeightPx) } fun moveCursor(dx: Float, dy: Float, fast: Boolean) { val step = if (fast) fastStep else baseStep cursorX += dx * step cursorY += dy * step clampCursor() } fun dispatchClickToWebView() { val wv = webViewRef ?: return val now = SystemClock.uptimeMillis() val x = cursorX val y = cursorY val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0) val up = MotionEvent.obtain(now, now + 50, MotionEvent.ACTION_UP, x, y, 0) wv.dispatchTouchEvent(down) wv.dispatchTouchEvent(up) down.recycle() up.recycle() logger.debug { "Dispatched click at ($x, $y)" } } Column( modifier = Modifier .fillMaxWidth(0.45f) .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surface) .onPreviewKeyEvent { event -> val isDown = event.nativeKeyEvent.action == KeyEvent.ACTION_DOWN val isLongPress = event.nativeKeyEvent.repeatCount > 0 val keyCode = event.nativeKeyEvent.keyCode if (!isDown) return@onPreviewKeyEvent false when (keyCode) { KeyEvent.KEYCODE_DPAD_UP -> { moveCursor(0f, -1f, isLongPress); true } KeyEvent.KEYCODE_DPAD_DOWN -> { moveCursor(0f, 1f, isLongPress); true } KeyEvent.KEYCODE_DPAD_LEFT -> { moveCursor(-1f, 0f, isLongPress); true } KeyEvent.KEYCODE_DPAD_RIGHT -> { moveCursor(1f, 0f, isLongPress); true } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { dispatchClickToWebView(); true } KeyEvent.KEYCODE_BACK -> { onDismiss(); true } else -> false } } .focusRequester(focusRequester) .focusable(), horizontalAlignment = Alignment.CenterHorizontally, ) { // ---- 状态提示 ---- Text( text = statusText, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 10.dp), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) // ---- WebView + 十字光标叠加 ---- Box( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) .padding(bottom = 4.dp), ) { AndroidView( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)), factory = { ctx -> val webView = WebView(ctx).apply { layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.mediaPlaybackRequiresUserGesture = false isFocusable = false isFocusableInTouchMode = false webChromeClient = WebChromeClient() addJavascriptInterface( object { @JavascriptInterface fun onGeetestResult( validate: String?, seccode: String?, geetestChallenge: String?, ) { val v = validate.orEmpty().trim() val s = seccode.orEmpty().trim() val c = geetestChallenge.orEmpty().trim() if (v.isBlank() || s.isBlank() || c.isBlank()) return logger.info { "Geetest verification succeeded" } onResult( GeetestTvResult( challenge = c, validate = v, seccode = s ) ) } @JavascriptInterface fun onStatusUpdate(text: String?) { text?.let { statusText = it } } }, "Android" ) loadDataWithBaseURL( "https://api.bilibili.com/", buildGeetestHtml(gt, challenge), "text/html", "utf-8", null, ) webViewRef = this } val overlay = CrosshairOverlayView(ctx).apply { layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) overlayRef = this } FrameLayout(ctx).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) addView(webView) addView(overlay) } }, update = { frame -> val wv = webViewRef ?: return@AndroidView wv.post { if (wv.width > 0 && wv.height > 0) { containerWidthPx = wv.width.toFloat() containerHeightPx = wv.height.toFloat() } } // 更新覆盖层光标位置 overlayRef?.setCursorPosition(cursorX, cursorY) }, ) } // ---- 操作提示 ---- Text( text = "方向键移动光标 | 确认键点击 | 返回键取消", modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, ) } } /** * 原生 View 十字光标覆盖层,渲染在 WebView 之上。 */ private class CrosshairOverlayView(context: Context) : View(context) { private var cx = 0f private var cy = 0f private val density = context.resources.displayMetrics.density private val armLen = 18f * density private val gap = 5f * density private val strokeW = 2f * density private val shadowW = 3.5f * density private val dotRadius = 3f * density private val dotShadowRadius = 4f * density private val circleRadius = armLen + gap private val whitePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFFFFFFF.toInt() style = Paint.Style.STROKE strokeWidth = strokeW } private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0x99000000.toInt() style = Paint.Style.STROKE strokeWidth = shadowW } private val dotWhitePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFFFFFFF.toInt() style = Paint.Style.FILL } private val dotShadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0x99000000.toInt() style = Paint.Style.FILL } private val dashPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xB3FFFFFF.toInt() style = Paint.Style.STROKE strokeWidth = 1f * density pathEffect = DashPathEffect(floatArrayOf(6f * density, 4f * density), 0f) } fun setCursorPosition(x: Float, y: Float) { cx = x cy = y invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (cx == 0f && cy == 0f) return // 阴影线 canvas.drawLine(cx, cy - gap - armLen, cx, cy - gap, shadowPaint) canvas.drawLine(cx, cy + gap, cx, cy + gap + armLen, shadowPaint) canvas.drawLine(cx - gap - armLen, cy, cx - gap, cy, shadowPaint) canvas.drawLine(cx + gap, cy, cx + gap + armLen, cy, shadowPaint) // 白色十字 canvas.drawLine(cx, cy - gap - armLen, cx, cy - gap, whitePaint) canvas.drawLine(cx, cy + gap, cx, cy + gap + armLen, whitePaint) canvas.drawLine(cx - gap - armLen, cy, cx - gap, cy, whitePaint) canvas.drawLine(cx + gap, cy, cx + gap + armLen, cy, whitePaint) // 中心点 canvas.drawCircle(cx, cy, dotShadowRadius, dotShadowPaint) canvas.drawCircle(cx, cy, dotRadius, dotWhitePaint) // 外圈虚线 canvas.drawCircle(cx, cy, circleRadius, dashPaint) } } /** * 构建加载极验验证码的 HTML 页面。 * * 使用极验 JS SDK (`gt.js`) 初始化验证并自动弹出验证窗口, * 用户在 WebView 中完成点选后,通过 JS bridge 回调原生代码。 */ private fun buildGeetestHtml(gt: String, challenge: String): String { // 防止参数注入 val safeGt = gt.replace("\\", "\\\\").replace("'", "\\'").replace("<", "<") val safeChallenge = challenge.replace("\\", "\\\\").replace("'", "\\'").replace("<", "<") return """
""".trimIndent() } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/LibVLCDownloaderDialog.kt ================================================ package dev.aaa1115910.bv.tv.component 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.tv.material3.Button import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.bv.network.VlcLibsApi import dev.aaa1115910.bv.player.BuildConfig import dev.aaa1115910.bv.util.toast import io.ktor.client.content.ProgressListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.util.UUID import java.util.zip.ZipInputStream @Composable fun LibVLCDownloaderDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit ) { val context = LocalContext.current val scope = rememberCoroutineScope() var processing by remember { mutableStateOf(false) } var text by remember { mutableStateOf("等待操作中...") } val unZipLibs: (zipFile: File) -> Unit = { zipFile -> val vlcLibsDir = File(context.filesDir, "vlc_libs") vlcLibsDir.mkdir() vlcLibsDir.listFiles()?.forEach { file -> file.delete() } ZipInputStream(zipFile.inputStream()) .use { zipInputStream -> generateSequence { zipInputStream.nextEntry } .map { UnzippedFile( filename = it.name, content = zipInputStream.readBytes() ) }.toList() } .forEach { println("Extracting ${it.filename}") val file = File(vlcLibsDir, it.filename) file.createNewFile() file.writeBytes(it.content) } } val startInstall: () -> Unit = { processing = true scope.launch(Dispatchers.IO) { runCatching { text = "正在获取下载地址" val release = VlcLibsApi.getRelease(BuildConfig.libVLCVersion) ?: throw IllegalStateException("Release not found") val tempFilename = "${UUID.randomUUID()}.zip" val tempDir = File(context.cacheDir, "libvlc_downloader") if (!tempDir.exists()) tempDir.mkdirs() val tempFile = File(tempDir, tempFilename) tempFile.createNewFile() VlcLibsApi.downloadFile( release, tempFile, object : ProgressListener { override suspend fun onProgress(downloaded: Long, total: Long?) { text = "正在下载(${downloaded / (total?.toFloat() ?: 0f) * 100}%)" } }) text = "正在解压" unZipLibs(tempFile) }.onSuccess { text = "安装完成" onHideDialog() withContext(Dispatchers.Main) { "LibVLC 安装成功".toast(context) } }.onFailure { text = "安装失败" withContext(Dispatchers.Main) { "LibVLC 安装失败: ${it.message}".toast(context) } it.printStackTrace() } processing = false } } if (show) { TvAlertDialog( modifier = modifier, title = { Text(text = "LibVLC 下载器") }, text = { Text(text = text) }, onDismissRequest = { if (!processing) onHideDialog() }, confirmButton = { Button( onClick = { startInstall() }, enabled = !processing ) { Text(text = "下载") } }, dismissButton = { OutlinedButton( onClick = { onHideDialog() }, enabled = !processing ) { Text(text = "取消") } } ) } } @Suppress("ArrayInDataClass") private data class UnzippedFile(val filename: String, val content: ByteArray) ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/LoadingTip.kt ================================================ package dev.aaa1115910.bv.tv.component import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun LoadingTip( modifier: Modifier = Modifier ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator( modifier = Modifier .size(36.dp) .padding(8.dp), color = MaterialTheme.colorScheme.onSurface, strokeWidth = 2.dp ) Text(text = stringResource(id = R.string.loading), fontSize = 22.sp) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun LoadingTipPreview() { BVTheme { LoadingTip() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/RemoteControlPanelDemo.kt ================================================ package dev.aaa1115910.bv.tv.component import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border 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.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.requestFocus @Composable fun RemoteControlPanelInfo() { ConstraintLayout { val (panelBorder, dPadBorder, centerBorder, backBorder) = createRefs() val borderWidth = 3.dp val contentColor = MaterialTheme.colorScheme.onSurface val tipTextStyle = MaterialTheme.typography.labelLarge.copy( fontSize = 24.sp, lineHeight = 32.sp, color = contentColor ) Box( modifier = Modifier .constrainAs(panelBorder) { centerTo(parent) } .size(200.dp, 400.dp) .border(borderWidth, contentColor, RoundedCornerShape(100.dp)) ) {} Box( modifier = Modifier .constrainAs(dPadBorder) { top.linkTo(panelBorder.top, 8.dp) start.linkTo(panelBorder.start) end.linkTo(panelBorder.end) } .size(180.dp) .border(borderWidth, contentColor, CircleShape) ) {} Box( modifier = Modifier .constrainAs(centerBorder) { centerTo(dPadBorder) } .size(70.dp) .border(borderWidth, contentColor, CircleShape) ) {} Box( modifier = Modifier .constrainAs(backBorder) { top.linkTo(dPadBorder.bottom, 16.dp) start.linkTo(panelBorder.start, 16.dp) } .size(70.dp) .border(borderWidth, contentColor, CircleShape), contentAlignment = Alignment.Center ) { Icon( modifier = Modifier.size(40.dp), imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = contentColor ) } val (tipLineBack, tipBack) = createRefs() Spacer( modifier = Modifier .constrainAs(tipLineBack) { end.linkTo(backBorder.start) top.linkTo(backBorder.top) bottom.linkTo(backBorder.bottom) } .width(80.dp) .height(borderWidth / 2) .background(contentColor) ) Text( modifier = Modifier .constrainAs(tipBack) { top.linkTo(tipLineBack.top) bottom.linkTo(tipLineBack.bottom) end.linkTo(tipLineBack.start, 8.dp) }, text = stringResource(R.string.remote_control_panel_demo_tip_back), style = tipTextStyle ) val (tipLineCenter, tipCenter) = createRefs() Spacer( modifier = Modifier .constrainAs(tipLineCenter) { start.linkTo(centerBorder.end) top.linkTo(centerBorder.top) bottom.linkTo(centerBorder.bottom) } .width(125.dp) .height(borderWidth / 2) .background(contentColor) ) Text( modifier = Modifier .constrainAs(tipCenter) { top.linkTo(tipLineCenter.top) bottom.linkTo(tipLineCenter.bottom) start.linkTo(tipLineCenter.end, 8.dp) }, text = stringResource(R.string.remote_control_panel_demo_tip_center), style = tipTextStyle ) val (tipLineUp, tipUp) = createRefs() Spacer( modifier = Modifier .constrainAs(tipLineUp) { start.linkTo(dPadBorder.end, (-80).dp) top.linkTo(dPadBorder.top, 20.dp) } .width(150.dp) .height(borderWidth / 2) .background(contentColor) ) Text( modifier = Modifier .constrainAs(tipUp) { top.linkTo(tipLineUp.top) bottom.linkTo(tipLineUp.bottom) start.linkTo(tipLineUp.end, 8.dp) }, text = stringResource(R.string.remote_control_panel_demo_tip_up), style = tipTextStyle ) val (tipLineDown, tipDown) = createRefs() Spacer( modifier = Modifier .constrainAs(tipLineDown) { start.linkTo(dPadBorder.end, (-80).dp) bottom.linkTo(dPadBorder.bottom, 20.dp) } .width(150.dp) .height(borderWidth / 2) .background(contentColor) ) Text( modifier = Modifier .constrainAs(tipDown) { top.linkTo(tipLineDown.top) bottom.linkTo(tipLineDown.bottom) start.linkTo(tipLineDown.end, 8.dp) }, text = stringResource(R.string.remote_control_panel_demo_tip_down), style = tipTextStyle ) val (tipLineLR1, tipLineLR2, tipLineLR3, tipLR) = createRefs() Spacer( modifier = Modifier .constrainAs(tipLineLR1) { end.linkTo(dPadBorder.end, 20.dp) top.linkTo(dPadBorder.top, 60.dp) } .rotate(45f) .width(40.dp) .height(borderWidth / 2) .background(contentColor) ) Spacer( modifier = Modifier .constrainAs(tipLineLR3) { end.linkTo(tipLineLR1.end, 120.dp) top.linkTo(tipLineLR1.top) } .rotate(45f) .width(40.dp) .height(borderWidth / 2) .background(contentColor) ) Spacer( modifier = Modifier .constrainAs(tipLineLR2) { end.linkTo(tipLineLR1.start, (-7).dp) top.linkTo(tipLineLR1.top, (-14).dp) } .width(200.dp) .height(borderWidth / 2) .background(contentColor) ) Text( modifier = Modifier .constrainAs(tipLR) { top.linkTo(tipLineLR2.top) bottom.linkTo(tipLineLR2.bottom) end.linkTo(tipLineLR2.start, 8.dp) }, text = stringResource(R.string.remote_control_panel_demo_tip_lr), style = tipTextStyle ) } } @Composable fun RemoteControlPanelDemo( modifier: Modifier = Modifier, onConfirm: () -> Unit = {} ) { val focusRequester = remember { FocusRequester() } val scope = rememberCoroutineScope() LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus(scope) } } Surface( modifier = modifier ) { Box( modifier = Modifier .focusRequester(focusRequester) .fillMaxSize() .clickable { onConfirm() }, contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { RemoteControlPanelInfo() Text( text = stringResource(R.string.remote_control_panel_demo_tip_bottom), color = MaterialTheme.colorScheme.onSurface ) } } } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun RemoteControlPanelInfoPreview() { BVTheme { Surface { RemoteControlPanelInfo() } } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun RemoteControlPanelDemoPreview() { BVTheme { Surface { RemoteControlPanelDemo() } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/SubCommentItem.kt ================================================ package dev.aaa1115910.bv.tv.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.bv.util.focusedBorder import dev.aaa1115910.bv.util.isDpadDown import dev.aaa1115910.bv.util.isKeyDown import dev.aaa1115910.bv.util.isDpadRight import kotlinx.coroutines.launch /** * 子评论项组件 * * 子评论有焦点边框,但不响应点击 * * @param comment 评论数据 * @param modifier 修饰符 * @param onLongClick 长按回调 */ @Composable fun SubCommentItem( comment: Comment, modifier: Modifier = Modifier, onLongClick: () -> Unit = {} ) { // 子评论有焦点边框,但不响应点击 Surface( modifier = modifier .fillMaxWidth() .focusedBorder(MaterialTheme.shapes.small), onClick = { /* 空回调,不执行任何操作 */ }, onLongClick = onLongClick, colors = ClickableSurfaceDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), pressedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) ), scale = ClickableSurfaceDefaults.scale( focusedScale = 1f, pressedScale = 1f ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.small) ) { Row( modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = androidx.compose.ui.Alignment.Top ) { // 头像 AsyncImage( model = comment.member.avatar, modifier = Modifier .size(32.dp) .clip(CircleShape), contentDescription = null, contentScale = ContentScale.Crop ) // 内容 Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp) ) { // 用户名 Text( text = comment.member.name, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.7f) ) // 评论内容(支持富文本表情) CommentContent( content = comment.content, emotes = comment.emotes ) // 评论图片 if (comment.pictures.isNotEmpty()) { CommentPictures( pictures = comment.pictures, modifier = Modifier.padding(top = 4.dp) ) } // 底部信息 Row( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = comment.timeDesc, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) Text( text = "${comment.like} 赞", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) } } } } } /** * 子评论根评论显示组件(只读,右键展开/收起,展开后下键滚动) * * @param comment 评论数据 * @param onLongClick 长按回调 */ @Composable fun SubCommentRootItem( comment: Comment, onLongClick: () -> Unit = {} ) { var expanded by remember { mutableStateOf(false) } var contentExceedsLimit by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } val scrollState = rememberScrollState() val scope = rememberCoroutineScope() val density = LocalDensity.current Surface( onClick = { /* 右键展开/收起 */ }, onLongClick = onLongClick, modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) .onPreviewKeyEvent { event -> when { // 右键展开/收起(仅内容超出高度限制时) event.isKeyDown() && event.isDpadRight() && contentExceedsLimit -> { expanded = !expanded if (expanded) { scope.launch { scrollState.scrollTo(0) } } true } // 展开状态下,下键滚动内容,不允许焦点转移 event.isKeyDown() && event.isDpadDown() && expanded -> { scope.launch { val scrollAmount = with(density) { 100.dp.toPx() } scrollState.animateScrollBy(scrollAmount) } true // 始终拦截事件,防止焦点转移 } else -> false } }, colors = ClickableSurfaceDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), pressedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f) ), scale = ClickableSurfaceDefaults.scale( focusedScale = 1f, pressedScale = 1f ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.small) ) { Column( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier .then( if (expanded) { Modifier.heightIn(max = 300.dp).verticalScroll(scrollState) } else { Modifier.heightIn(max = 150.dp) } ) .onSizeChanged { size -> if (!expanded && !contentExceedsLimit) { val limitPx = with(density) { 150.dp.toPx() } contentExceedsLimit = size.height >= limitPx.toInt() } }, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = androidx.compose.ui.Alignment.Top ) { AsyncImage( model = comment.member.avatar, modifier = Modifier .size(40.dp) .clip(CircleShape), contentDescription = null, contentScale = ContentScale.Crop ) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = comment.member.name, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f) ) // 评论内容(支持富文本表情,最多3行) CommentContent( content = comment.content, emotes = comment.emotes, maxLines = if (expanded) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis ) // 评论图片 if (comment.pictures.isNotEmpty()) { CommentPictures( pictures = comment.pictures, modifier = Modifier.padding(top = 4.dp) ) } Row( horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = comment.timeDesc, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) Text( text = "${comment.like} 赞", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) } } } // 展开/收起提示(仅内容超出高度限制时显示) if (contentExceedsLimit) { Text( text = if (expanded) "右键收起 <<" else "右键展开 >>", style = MaterialTheme.typography.bodySmall, color = Color.White, modifier = Modifier.padding(start = 48.dp) ) } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/SubCommentPanel.kt ================================================ package dev.aaa1115910.bv.tv.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandHorizontally import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Divider 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.biliapi.entity.reply.Comment import dev.aaa1115910.biliapi.entity.reply.CommentReplyPage import dev.aaa1115910.biliapi.repositories.CommentRepository import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.focusedBorder import dev.aaa1115910.bv.util.isDpadDown import dev.aaa1115910.bv.util.isKeyDown import dev.aaa1115910.bv.util.onBackPressed import dev.aaa1115910.bv.util.requestFocus import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.getKoin /** * 子评论浮窗组件 * * @param show 是否显示浮窗 * @param oid 视频 ID * @param rootId 根评论 ID * @param rootComment 根评论数据 * @param onHide 关闭回调 */ @Composable fun SubCommentPanel( show: Boolean, oid: Long, rootId: Long, rootComment: Comment, onHide: () -> Unit ) { val commentRepository: CommentRepository = getKoin().get() val scope = rememberCoroutineScope() val listState = rememberLazyListState() val focusRequester = remember { FocusRequester() } val density = LocalDensity.current val replies = remember { mutableStateListOf() } var focusedCommentIndex by remember { mutableStateOf(0) } var loading by remember { mutableStateOf(false) } var currentPage by remember { mutableStateOf(CommentReplyPage()) } var hasNext by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } // 全屏图片查看器状态 var showImageViewer by remember { mutableStateOf(false) } var imageViewerPictures by remember { mutableStateOf>(emptyList()) } // 加载子评论 val loadReplies: (Boolean) -> Unit = { reset -> scope.launch { if (loading) return@launch loading = true error = null try { val page = if (reset) CommentReplyPage() else currentPage val data = commentRepository.getCommentReplies( rpid = rootId, type = 1L, commentId = oid, page = page, preferApiType = Prefs.apiType ) if (reset) { replies.clear() replies.addAll(data.replies) } else { replies.addAll(data.replies) } currentPage = data.nextPage hasNext = data.hasNext } catch (e: Exception) { error = e.message ?: "加载失败" } finally { loading = false } } } // 显示时加载第一页 LaunchedEffect(show, rootId) { if (show && replies.isEmpty()) { loadReplies(true) } } // 显示后请求焦点 LaunchedEffect(show, replies.isNotEmpty()) { if (show && replies.isNotEmpty()) { delay(300) focusRequester.requestFocus(scope) } } // 懒加载 val isAtBottom by remember { derivedStateOf { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == replies.size - 1 } } LaunchedEffect(isAtBottom, hasNext, loading) { if (isAtBottom && hasNext && !loading && replies.isNotEmpty()) { loadReplies(false) } } Box( modifier = Modifier .fillMaxSize() .clickable(onClick = onHide), contentAlignment = Alignment.CenterEnd ) { AnimatedVisibility( visible = show, enter = expandHorizontally(expandFrom = Alignment.End), exit = shrinkHorizontally(shrinkTowards = Alignment.End) ) { Surface( modifier = Modifier .fillMaxHeight() .padding(horizontal = 16.dp, vertical = 16.dp) .widthIn(min = 320.dp, max = 420.dp) .fillMaxWidth(0.3f) .clickable(enabled = true, onClick = {}) .onBackPressed { onHide() }, colors = SurfaceDefaults.colors( containerColor = Color.Black.copy(alpha = 0.85f) ), shape = MaterialTheme.shapes.large ) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { // 根评论(只读显示,右键展开/收起) SubCommentRootItem( comment = rootComment, onLongClick = { if (rootComment.pictures.isNotEmpty()) { imageViewerPictures = rootComment.pictures showImageViewer = true } } ) // 分隔线 Divider( color = Color.White.copy(alpha = 0.2f), thickness = 1.dp ) // 子评论列表 if (error != null) { Text( text = error ?: "加载失败", color = Color.Red, modifier = Modifier.padding(16.dp) ) } else if (replies.isEmpty() && !loading) { Text( text = "暂无回复", color = Color.White.copy(alpha = 0.5f), modifier = Modifier.padding(16.dp) ) } else { LazyColumn( modifier = Modifier .weight(1f) .fillMaxWidth() .focusRequester(focusRequester) .onPreviewKeyEvent { event -> when { // 左键返回顶部 event.isKeyDown() && event.key == Key.DirectionLeft -> { scope.launch { listState.scrollToItem(0) delay(100) focusRequester.requestFocus(scope) } true } // 下键:逐步滚动,在列表末尾时阻止焦点移出 event.isKeyDown() && event.isDpadDown() -> { val layoutInfo = listState.layoutInfo // 已聚焦到最后一条回复时,阻止焦点移出列表 if (focusedCommentIndex >= replies.size - 1 && replies.isNotEmpty()) { true } else { val currentItemInfo = layoutInfo.visibleItemsInfo .firstOrNull { it.index == focusedCommentIndex } if (currentItemInfo != null) { val viewportEnd = layoutInfo.viewportEndOffset val itemBottom = currentItemInfo.offset + currentItemInfo.size // 如果评论底部不可见,逐步滚动 if (itemBottom > viewportEnd) { scope.launch { // 每次滚动约 100dp val scrollAmount = with(density) { 100.dp.toPx() } listState.animateScrollBy(scrollAmount) } true // 拦截事件,不允许焦点转移 } else { false // 评论已完全可见,允许焦点转移 } } else { false } } } else -> false } }, state = listState, verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = replies, key = { index, it -> "$index-reply-${it.rpid}" } ) { index, reply -> SubCommentItem( comment = reply, modifier = Modifier .fillMaxWidth() .onFocusChanged { focusState -> if (focusState.hasFocus) { focusedCommentIndex = index } }, onLongClick = { if (reply.pictures.isNotEmpty()) { imageViewerPictures = reply.pictures showImageViewer = true } } ) } if (loading) { item { Row( modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center ) { LoadingTip() } } } if (!hasNext && replies.isNotEmpty()) { item { Text( modifier = Modifier.fillMaxWidth().padding(16.dp), text = "没有更多了", style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.5f) ) } } } } // 底部提示 Spacer(modifier = Modifier.padding(bottom = 8.dp)) } } } } // 全屏图片查看器 if (showImageViewer && imageViewerPictures.isNotEmpty()) { FullscreenImageViewer( pictures = imageViewerPictures, onDismiss = { showImageViewer = false imageViewerPictures = emptyList() } ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/TopNav.kt ================================================ package dev.aaa1115910.bv.tv.component import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowScope import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.pgc.PgcType import dev.aaa1115910.biliapi.entity.ugc.UgcTypeV2 import dev.aaa1115910.bv.BVApp import dev.aaa1115910.bv.entity.NavSwitchMode import dev.aaa1115910.bv.util.getDisplayName import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.isKeyDown import kotlinx.coroutines.delay @OptIn(ExperimentalComposeUiApi::class) @Composable fun TopNav( modifier: Modifier = Modifier, paddingTop: Dp = 12.dp, items: List, useSmallSize: Boolean = false, initialSelectedItem: TopNavItem? = null, navSwitchMode: NavSwitchMode = NavSwitchMode.Auto, tabFocusRequester: FocusRequester? = null, itemFocusRequesterProvider: ((Int, TopNavItem) -> FocusRequester?)? = null, onSelectedChanged: (TopNavItem) -> Unit = {}, onClick: (TopNavItem) -> Unit = {}, onLeftKeyEvent: () -> Unit = {} ) { val sizeScale = if (useSmallSize) 0.8f else 1f val topPadding = paddingTop * sizeScale val horizontalPadding = 12.dp * (if (useSmallSize) 1 / sizeScale else 1f) val bottomPadding = 8.dp * sizeScale val separatorWidth = 12.dp * sizeScale if (items.isEmpty()) { Row( modifier = modifier .fillMaxWidth() .padding( top = topPadding, bottom = bottomPadding, start = horizontalPadding, end = horizontalPadding ), horizontalArrangement = Arrangement.Center ) {} return } val defaultFocusRequester = tabFocusRequester ?: remember { FocusRequester() } var selectedNav by remember(initialSelectedItem) { mutableStateOf(initialSelectedItem ?: items.first()) } var selectedTabIndex by remember(initialSelectedItem) { mutableIntStateOf( if (initialSelectedItem != null) { val index = items.indexOf(initialSelectedItem) if (index >= 0) index else 0 } else 0 ) } // 在确认键切换模式下,focusedTabIndex 跟踪当前聚焦的 tab(视觉高亮) var focusedTabIndex by remember(initialSelectedItem) { mutableIntStateOf( if (initialSelectedItem != null) { val index = items.indexOf(initialSelectedItem) if (index >= 0) index else 0 } else 0 ) } var tabMoved by remember { mutableStateOf(true) } val currentTabFocusRequester = run { val focusIndex = (if (navSwitchMode == NavSwitchMode.Confirm) focusedTabIndex else selectedTabIndex) .coerceIn(items.indices) itemFocusRequesterProvider?.invoke(focusIndex, items[focusIndex]) ?: defaultFocusRequester } LaunchedEffect(items, initialSelectedItem) { if (items.isEmpty()) return@LaunchedEffect val nextSelectedItem = initialSelectedItem?.takeIf { it in items } ?: items.first() selectedNav = nextSelectedItem val idx = items.indexOf(nextSelectedItem).takeIf { it >= 0 } ?: 0 selectedTabIndex = idx focusedTabIndex = idx tabMoved = true } LaunchedEffect(selectedNav) { if (selectedNav !in items) return@LaunchedEffect delay(200) onSelectedChanged(selectedNav) // 别急着向下移动焦点,动画还没结束 delay(400) tabMoved = true } Row( modifier = modifier .fillMaxWidth() .padding( top = topPadding, bottom = bottomPadding, start = horizontalPadding, end = horizontalPadding ) .onFocusChanged { if (!it.hasFocus) { focusedTabIndex = selectedTabIndex } }, horizontalArrangement = Arrangement.Center ) { TabRow( modifier = Modifier .then( if (navSwitchMode == NavSwitchMode.Auto) { Modifier.focusRestorer(currentTabFocusRequester) } else { Modifier.focusProperties { enter = { currentTabFocusRequester } } } ) .onPreviewKeyEvent { if (it.isKeyDown()) { val currentFocusIndex = if (navSwitchMode == NavSwitchMode.Confirm) focusedTabIndex else selectedTabIndex if (it.key == Key.DirectionLeft && currentFocusIndex == 0) { onLeftKeyEvent() return@onPreviewKeyEvent true } if (it.key == Key.DirectionDown) { return@onPreviewKeyEvent !tabMoved } } false }, selectedTabIndex = selectedTabIndex, indicator = { _, _ -> }, separator = { Spacer(modifier = Modifier.width(separatorWidth)) }, ) { items.forEachIndexed { index, tab -> val itemFocusRequester = itemFocusRequesterProvider?.invoke(index, tab) val itemFocusModifier = itemFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier val useSharedFocusRequester = itemFocusRequester == null && if (navSwitchMode == NavSwitchMode.Confirm) index == focusedTabIndex else index == selectedTabIndex NavItemTab( modifier = Modifier .then(itemFocusModifier) .ifElse( useSharedFocusRequester, Modifier.focusRequester(defaultFocusRequester) ), topNavItem = tab, useSmallSize = useSmallSize, selected = index == selectedTabIndex, focused = navSwitchMode == NavSwitchMode.Confirm && index == focusedTabIndex && index != selectedTabIndex, onFocus = { if (navSwitchMode == NavSwitchMode.Auto) { // 自动切换模式:聚焦即切换 val isSameTab = tab == selectedNav selectedNav = tab selectedTabIndex = index if (!isSameTab) { tabMoved = false } } else { // 确认键切换模式:聚焦只更新视觉状态 focusedTabIndex = index } }, onClick = { if (navSwitchMode == NavSwitchMode.Confirm) { // 确认键切换模式:按确认键才切换 val isSameTab = tab == selectedNav selectedNav = tab selectedTabIndex = index focusedTabIndex = index if (!isSameTab) { tabMoved = false } } onClick(tab) } ) } } } } @Composable private fun TabRowScope.NavItemTab( modifier: Modifier = Modifier, topNavItem: TopNavItem, useSmallSize: Boolean = false, selected: Boolean, focused: Boolean = false, onClick: () -> Unit, onFocus: () -> Unit ) { val context = LocalContext.current var isFocused by remember { mutableStateOf(false) } val sizeScale = if (useSmallSize) 0.85f else 1f val tabHeight = 32.dp * sizeScale val tabHorizontalPadding = 16.dp * sizeScale val tabCornerRadius = 50.dp * sizeScale val baseTextStyle = MaterialTheme.typography.bodyLarge val textStyle = if (useSmallSize) { baseTextStyle.copy( fontSize = if (baseTextStyle.fontSize.isSpecified) baseTextStyle.fontSize * sizeScale else TextUnit.Unspecified, lineHeight = if (baseTextStyle.lineHeight.isSpecified) baseTextStyle.lineHeight * sizeScale else TextUnit.Unspecified, letterSpacing = if (baseTextStyle.letterSpacing.isSpecified) baseTextStyle.letterSpacing * sizeScale else TextUnit.Unspecified ) } else { baseTextStyle } Tab( modifier = modifier.onFocusChanged { isFocused = it.hasFocus }, selected = selected, onFocus = onFocus, onClick = onClick ) { val actualFocused = isFocused || focused Text( modifier = Modifier .height(tabHeight) .ifElse( !actualFocused && selected, Modifier.background( color = MaterialTheme.colorScheme.inverseSurface, shape = RoundedCornerShape(tabCornerRadius) ) ) .ifElse( actualFocused && !selected, Modifier.background( color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.3f), shape = RoundedCornerShape(tabCornerRadius) ) ) .ifElse( actualFocused && selected, Modifier.background( color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.75f), shape = RoundedCornerShape(tabCornerRadius) ) ) .wrapContentHeight(Alignment.CenterVertically) .padding(horizontal = tabHorizontalPadding), text = topNavItem.getDisplayName(context), style = textStyle, color = if (!actualFocused && selected) MaterialTheme.colorScheme.surface else if (actualFocused && !selected) MaterialTheme.colorScheme.inverseSurface else if (actualFocused && selected) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.inverseSurface ) } } interface TopNavItem { fun getDisplayName(context: Context = BVApp.context): String } enum class HomeTopNavItem(private val displayName: String) : TopNavItem { Recommend("推荐"), Popular("热门"), Dynamics("动态"), History("历史"), Favorite("收藏"), FollowingSeason("追番"), ToView("稍后再看"); override fun getDisplayName(context: Context): String { return displayName } } enum class UgcTopNavItem(private val ugcType: UgcTypeV2) : TopNavItem { Douga(UgcTypeV2.Douga), Game(UgcTypeV2.Game), Kichiku(UgcTypeV2.Kichiku), Music(UgcTypeV2.Music), Dance(UgcTypeV2.Dance), Cinephile(UgcTypeV2.Cinephile), Ent(UgcTypeV2.Ent), Knowledge(UgcTypeV2.Knowledge), Tech(UgcTypeV2.Tech), Information(UgcTypeV2.Information), Food(UgcTypeV2.Food), ShortPlay(UgcTypeV2.Shortplay), Car(UgcTypeV2.Car), Fashion(UgcTypeV2.Fashion), Sports(UgcTypeV2.Sports), Animal(UgcTypeV2.Animal), Vlog(UgcTypeV2.Vlog), Painting(UgcTypeV2.Painting), Ai(UgcTypeV2.Ai), Home(UgcTypeV2.Home), Outdoors(UgcTypeV2.Outdoors), Gym(UgcTypeV2.Gym), Handmake(UgcTypeV2.Handmake), Travel(UgcTypeV2.Travel), Rural(UgcTypeV2.Rural), Parenting(UgcTypeV2.Parenting), Health(UgcTypeV2.Health), Emotion(UgcTypeV2.Emotion), LifeJoy(UgcTypeV2.LifeJoy), LifeExperience(UgcTypeV2.LifeExperience), Mysticism(UgcTypeV2.Mysticism); override fun getDisplayName(context: Context): String { return ugcType.getDisplayName(context) } } enum class PgcTopNavItem(private val pgcType: PgcType) : TopNavItem { Anime(PgcType.Anime), GuoChuang(PgcType.GuoChuang), Movie(PgcType.Movie), Documentary(PgcType.Documentary), Tv(PgcType.Tv), Variety(PgcType.Variety); override fun getDisplayName(context: Context): String { return pgcType.getDisplayName(context) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/TvAlertDialog.kt ================================================ package dev.aaa1115910.bv.tv.component import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun TvAlertDialog( onDismissRequest: () -> Unit, confirmButton: @Composable () -> Unit, modifier: Modifier = Modifier, dismissButton: @Composable (() -> Unit)? = null, icon: @Composable (() -> Unit)? = null, title: @Composable (() -> Unit)? = null, text: @Composable (() -> Unit)? = null, shape: Shape = AlertDialogDefaults.shape, containerColor: Color = AlertDialogDefaults.containerColor, iconContentColor: Color = AlertDialogDefaults.iconContentColor, titleContentColor: Color = AlertDialogDefaults.titleContentColor, textContentColor: Color = AlertDialogDefaults.textContentColor, tonalElevation: Dp = AlertDialogDefaults.TonalElevation, properties: DialogProperties = DialogProperties() ) { AlertDialog( onDismissRequest = onDismissRequest, confirmButton = confirmButton, modifier = modifier, dismissButton = dismissButton, icon = icon, title = (@Composable { ProvideTextStyle( value = MaterialTheme.typography.headlineSmall ) { title?.invoke() } }).takeIf { title != null }, text = (@Composable { ProvideTextStyle( value = MaterialTheme.typography.bodyMedium ) { text?.invoke() } }).takeIf { title != null }, shape = shape, containerColor = containerColor, iconContentColor = iconContentColor, titleContentColor = titleContentColor, textContentColor = textContentColor, tonalElevation = tonalElevation, properties = properties ) } @Preview @Composable private fun DialogPreview() { BVTheme { TvAlertDialog( title = { Text(text = "Dialog Title") }, text = { Column { Text(text = "This is a sample dialog text. It can be used to display information or ask for user input.") Text( text = "This is a sample dialog text. It can be used to display information or ask for user input.", style = MaterialTheme.typography.bodyMedium ) } }, onDismissRequest = {}, confirmButton = { OutlinedButton(onClick = {}) { Text(text = "Confirm") } }, ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/UpIcon.kt ================================================ package dev.aaa1115910.bv.tv.component import android.content.res.Configuration import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme @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 @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun UpIconPreview() { BVTheme { Row( verticalAlignment = Alignment.CenterVertically ) { UpIcon() Text(text = "bishi") } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/UserPanel.kt ================================================ package dev.aaa1115910.bv.tv.component import android.content.res.Configuration import android.view.KeyEvent 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.height 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.material.icons.Icons import androidx.compose.material.icons.rounded.CrueltyFree import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.Schedule import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.requestFocus private val lineHeight = 80.dp @Composable fun UserPanel( modifier: Modifier = Modifier, username: String, face: String, onHide: () -> Unit, onGoMy: () -> Unit, onGoHistory: () -> Unit, onGoFavorite: () -> Unit, onGoFollowing: () -> Unit, onGoLater: () -> Unit ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus(scope) } Box( modifier = modifier .onPreviewKeyEvent { when (it.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_BACK -> { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) onHide() return@onPreviewKeyEvent true } } false } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp) ) { UserPanelMyItem( modifier = Modifier .width(300.dp) .focusRequester(focusRequester) .onPreviewKeyEvent { when (it.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_LEFT -> { return@onPreviewKeyEvent true } } false }, username = username, face = face, onClick = { onGoMy() onHide() } ) val buttonWidth = 120.dp Row { UserPanelSmallItem( modifier = Modifier .width(buttonWidth) .onPreviewKeyEvent { when (it.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_LEFT -> { return@onPreviewKeyEvent true } } false }, title = "历史记录", icon = Icons.Rounded.History, onClick = { onGoHistory() onHide() } ) UserPanelSmallItem( modifier = Modifier .width(buttonWidth), title = "私人藏品", icon = Icons.Rounded.FavoriteBorder, onClick = { onGoFavorite() onHide() } ) UserPanelSmallItem( modifier = Modifier .width(buttonWidth), title = "我追的番", icon = Icons.Rounded.CrueltyFree, onClick = { onGoFollowing() onHide() } ) UserPanelSmallItem( modifier = Modifier .width(buttonWidth), title = "现在不看", icon = Icons.Rounded.Schedule, onClick = { onGoLater() onHide() } ) } } } } @Composable private fun UserPanelMyItem( modifier: Modifier = Modifier, username: String, face: String, onClick: () -> Unit ) { Surface( modifier = modifier .padding(4.dp) .fillMaxWidth() .height(lineHeight), onClick = onClick, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ) ) { Row( modifier = Modifier .fillMaxSize() .padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier .fillMaxHeight() .padding(end = 40.dp), verticalArrangement = Arrangement.SpaceAround ) { Text(text = username) Text(text = "") } AsyncImage( modifier = Modifier .size(40.dp) .clip(CircleShape), model = face, contentDescription = null, contentScale = ContentScale.FillBounds ) } } } @Composable private fun UserPanelSmallItem( modifier: Modifier = Modifier, title: String, icon: ImageVector, onClick: () -> Unit ) { Surface( modifier = modifier .padding(4.dp) .height(lineHeight), onClick = onClick, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ) ) { Box( modifier = Modifier.fillMaxSize() ) { Icon( modifier = Modifier .padding(12.dp) .align(Alignment.TopStart), imageVector = icon, contentDescription = null ) Text( modifier = Modifier .padding(12.dp) .align(Alignment.BottomStart), text = title, style = MaterialTheme.typography.bodyLarge ) } } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun UserPanelPreview() { BVTheme { UserPanel( username = "", face = "", onHide = {}, onGoMy = {}, onGoHistory = {}, onGoFollowing = {}, onGoFavorite = {}, onGoLater = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/buttons/CoinButton.kt ================================================ package dev.aaa1115910.bv.tv.component.buttons import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Paid import androidx.compose.material.icons.outlined.Paid import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.ButtonBorder import androidx.tv.material3.ButtonColors import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun CoinButton( modifier: Modifier = Modifier, isCoin: Boolean, onAddCoin: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 6.dp), // 减小内边距 colors: ButtonColors = ButtonDefaults.colors(), border: ButtonBorder = ButtonDefaults.border() ) { Button( modifier = modifier, contentPadding = contentPadding, colors = colors, border = border, shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)), // 设置为小圆角 onClick = {onAddCoin()} ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), // 减小间距 verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier .size(16.dp), imageVector = if (isCoin) Icons.Rounded.Paid else Icons.Outlined.Paid, contentDescription = null, tint = if (isCoin) Color(0xfffb7299) else LocalContentColor.current ) Text( text = stringResource(R.string.coin_button_text) ) } } } @Preview @Composable fun CoinButtonEnablePreview() { BVTheme { CoinButton( isCoin = true ) } } @Preview @Composable fun CoinButtonDisablePreview() { BVTheme { CoinButton( isCoin = false ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/buttons/FavoriteButton.kt ================================================ package dev.aaa1115910.bv.tv.component.buttons import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row 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.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Done import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material3.AlertDialogDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.ButtonBorder import androidx.tv.material3.ButtonColors import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.FilterChip import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.FavoriteFolderMetadata import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.manager.VideoUserActionManager import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.swapList import kotlinx.coroutines.delay @Composable fun FavoriteButton( modifier: Modifier = Modifier, isFavorite: Boolean, favoriteFolderIds: List = emptyList(), onAddToDefaultFavoriteFolder: () -> Unit, onUpdateFavoriteFolders: (List) -> Unit, contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 6.dp), // 减小内边距 colors: ButtonColors = ButtonDefaults.colors(), border: ButtonBorder = ButtonDefaults.border(), onDialogVisibilityChanged: (Boolean) -> Unit = {}, dialogContainerColor: Color = AlertDialogDefaults.containerColor ) { val userFavoriteFolders by VideoUserActionManager.getFavoriteFoldersFlow().collectAsState() var showFavoriteDialog by remember { mutableStateOf(false) } LaunchedEffect(showFavoriteDialog) { onDialogVisibilityChanged(showFavoriteDialog) } Button( modifier = modifier, contentPadding = contentPadding, colors = colors, border = border, shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)), // 设置为小圆角 onClick = { if (showFavoriteDialog) return@Button if (isFavorite) { // 有收藏状态,显示收藏夹选择对话框 showFavoriteDialog = true } else { // 无收藏状态 if (userFavoriteFolders.size > 1) { // 有多个收藏夹,显示收藏夹选择对话框 showFavoriteDialog = true } else { // 否则使用默认收藏夹 onAddToDefaultFavoriteFolder() } } } ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), // 减小间距 verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier .size(16.dp), imageVector = if (isFavorite) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, contentDescription = null, tint = if (isFavorite) Color(0xfffb7299) else LocalContentColor.current ) Text( text = stringResource(R.string.favorite_button_text) ) } } FavoriteDialog( show = showFavoriteDialog, onHideDialog = { showFavoriteDialog = false }, userFavoriteFolders = userFavoriteFolders, favoriteFolderIds = favoriteFolderIds, onUpdateFavoriteFolders = onUpdateFavoriteFolders, dialogContainerColor = dialogContainerColor ) } @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun FavoriteDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, userFavoriteFolders: List = emptyList(), favoriteFolderIds: List = emptyList(), onUpdateFavoriteFolders: (List) -> Unit, dialogContainerColor: Color = AlertDialogDefaults.containerColor ) { val selectedFavoriteFolderIds = remember { mutableStateListOf() } val defaultFocusRequester = remember { FocusRequester() } var lastInteractionTime by remember { mutableStateOf(System.currentTimeMillis()) } fun touch() { lastInteractionTime = System.currentTimeMillis() } LaunchedEffect(show) { if (show) { selectedFavoriteFolderIds.swapList(favoriteFolderIds) defaultFocusRequester.requestFocus() // 打开时更新交互时间 touch() } } // 10 秒无操作自动关闭 LaunchedEffect(lastInteractionTime, show) { if (show) { val base = lastInteractionTime delay(10000) if (base == lastInteractionTime) onHideDialog() } } if (show) { TvAlertDialog( modifier = modifier, containerColor = dialogContainerColor, onDismissRequest = onHideDialog, confirmButton = {}, title = { Text(text = stringResource(R.string.favorite_dialog_title)) }, text = { FlowRow( modifier = Modifier .width(400.dp) .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { userFavoriteFolders.forEachIndexed { index, userFavoriteFolder -> val selected = selectedFavoriteFolderIds.contains(userFavoriteFolder.id) var hasFocus by remember { mutableStateOf(false) } val itemModifier = if (index == 0) Modifier.focusRequester(defaultFocusRequester) else Modifier FilterChip( modifier = itemModifier.onFocusChanged { hasFocus = it.hasFocus if (it.hasFocus) touch() }, selected = selected, onClick = { if (selectedFavoriteFolderIds.contains(userFavoriteFolder.id)) { selectedFavoriteFolderIds.remove(userFavoriteFolder.id) } else { selectedFavoriteFolderIds.add(userFavoriteFolder.id) } onUpdateFavoriteFolders(selectedFavoriteFolderIds) // 点击交互更新最后交互时间 touch() }, leadingIcon = { Row { if(selected) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Rounded.Done, contentDescription = null ) } } } ) { Text(text = userFavoriteFolder.title) } } } } ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun FavoriteButtonEnablePreview() { BVTheme { FavoriteButton( isFavorite = true, onAddToDefaultFavoriteFolder = {}, onUpdateFavoriteFolders = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun FavoriteButtonDisablePreview() { BVTheme { FavoriteButton( isFavorite = false, onAddToDefaultFavoriteFolder = {}, onUpdateFavoriteFolders = {} ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun FavoriteDialogPreview() { val userFavoriteFolders = listOf( FavoriteFolderMetadata(0, 0, 0, "收藏夹1", null, false, 0), FavoriteFolderMetadata(1, 1, 0, "收藏夹2", null, false, 0), FavoriteFolderMetadata(2, 2, 0, "收藏夹3", null, false, 0), FavoriteFolderMetadata(3, 3, 0, "收藏夹4", null, false, 0), FavoriteFolderMetadata(4, 4, 0, "收藏夹5", null, false, 0), FavoriteFolderMetadata(5, 5, 0, "收藏夹6", null, false, 0), FavoriteFolderMetadata(6, 6, 0, "收藏夹7", null, false, 0), FavoriteFolderMetadata(7, 7, 0, "收藏夹8", null, false, 0), FavoriteFolderMetadata(8, 8, 0, "收藏夹9", null, false, 0), FavoriteFolderMetadata(9, 9, 0, "收藏夹10", null, false, 0), ) BVTheme { FavoriteDialog( show = true, onHideDialog = {}, userFavoriteFolders = userFavoriteFolders, onUpdateFavoriteFolders = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/buttons/LikeButton.kt ================================================ package dev.aaa1115910.bv.tv.component.buttons import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material.icons.rounded.ThumbUp import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.ButtonBorder import androidx.tv.material3.ButtonColors import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun LikeButton( modifier: Modifier = Modifier, isLike: Boolean, onToggleLike: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 6.dp), // 减小内边距 colors: ButtonColors = ButtonDefaults.colors(), border: ButtonBorder = ButtonDefaults.border() ) { Button( modifier = modifier, onClick = {onToggleLike()}, contentPadding = contentPadding, shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)), // 设置为小圆角 colors = colors, border = border, ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), // 减小间距 verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier .size(16.dp), imageVector = if (isLike) Icons.Rounded.ThumbUp else Icons.Outlined.ThumbUp, contentDescription = null, tint = if (isLike) Color(0xfffb7299) else LocalContentColor.current ) Text( text = stringResource(R.string.like_button_text) ) } } } @Preview @Composable fun LikeButtonEnablePreview() { BVTheme { LikeButton( isLike = true ) } } @Preview @Composable fun LikeButtonDisablePreview() { BVTheme { LikeButton( isLike = false ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/buttons/SeasonInfoButtons.kt ================================================ package dev.aaa1115910.bv.tv.component.buttons 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.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.Icon import androidx.tv.material3.IconButton import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButtonDefaults import androidx.tv.material3.Text import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.focusedBorder @Composable fun SeasonInfoButtons( modifier: Modifier = Modifier, lastPlayedIndex: Int, lastPlayedTitle: String = "", following: Boolean, isPublished: Boolean, publishDate: String, onPlay: () -> Unit, onClickFollow: (follow: Boolean) -> Unit, onShowComment: () -> Unit = {}, commentButtonFocusRequester: FocusRequester = remember { FocusRequester() }, playButtonFocusRequester: FocusRequester = remember { FocusRequester() } ) { Row( modifier = modifier .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { if (isPublished) { Button( onClick = onPlay, modifier = Modifier.focusRequester(playButtonFocusRequester) ) { Row( verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Rounded.PlayArrow, contentDescription = null ) Text(text = if (lastPlayedIndex == -1) "开始播放" else lastPlayedTitle) } } } else { Button(onClick = {}) { Text(text = publishDate) } } // 追番按钮 FollowSeasonButton( following = following, onClick = onClickFollow ) // 评论按钮 Column ( modifier = Modifier .focusRequester(commentButtonFocusRequester) .clip(MaterialTheme.shapes.small) .background(Color.White.copy(alpha = 0.2f)) .focusedBorder(MaterialTheme.shapes.small) .padding(horizontal = 8.dp, vertical = 4.dp) .clickable { onShowComment() }, verticalArrangement = Arrangement.Center, ) { Text( text = "评论>>", color = MaterialTheme.colorScheme.onSurface ) } } } @Composable fun FollowSeasonButton( modifier: Modifier = Modifier, following: Boolean, onClick: (follow: Boolean) -> Unit ) { IconButton( modifier = modifier, onClick = { onClick(!following) }, colors = OutlinedButtonDefaults.colors(), border = OutlinedButtonDefaults.border() ) { Box( modifier = Modifier.size(20.dp) ) { if (following) { Icon( imageVector = Icons.Rounded.Favorite, contentDescription = null, tint = Color(0xfffb7299) ) } else { Icon( imageVector = Icons.Rounded.FavoriteBorder, contentDescription = null ) } } } } @Preview @Composable fun SeasonInfoButtonsPreview() { BVTheme { SeasonInfoButtons( lastPlayedIndex = 3, lastPlayedTitle = "拯救灵依计划", following = false, isPublished = true, publishDate = "2021-10-01", onPlay = {}, onClickFollow = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/buttons/ToViewButton.kt ================================================ package dev.aaa1115910.bv.tv.component.buttons import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AccessTime import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.ButtonBorder import androidx.tv.material3.ButtonColors import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.Icon import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun ToViewButton( modifier: Modifier = Modifier, onAddToView: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 6.dp), colors: ButtonColors = ButtonDefaults.colors(), border: ButtonBorder = ButtonDefaults.border() ) { Button( modifier = modifier, onClick = { onAddToView() }, contentPadding = contentPadding, shape = ButtonDefaults.shape(shape = RoundedCornerShape(8.dp)), colors = colors, border = border, ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier.size(16.dp), imageVector = Icons.Outlined.AccessTime, contentDescription = null ) Text( text = stringResource(R.string.toview_button_text) ) } } } @Preview @Composable fun ToViewButtonPreview() { BVTheme { ToViewButton() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/live/LiveRoomCard.kt ================================================ package dev.aaa1115910.bv.tv.component.live import androidx.compose.foundation.background import androidx.compose.foundation.border 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape 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.focus.onFocusChanged 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.unit.dp import androidx.compose.ui.unit.sp import java.util.Locale import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.live.LiveRoomItem import dev.aaa1115910.bv.R import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.resizedImageUrl @Composable fun LiveRoomCard( modifier: Modifier = Modifier, data: LiveRoomItem, onClick: () -> Unit = {}, onFocus: () -> Unit = {} ) { var hasFocus by remember { mutableStateOf(false) } Surface( modifier = modifier .fillMaxWidth() .onFocusChanged { hasFocus = it.isFocused if (hasFocus) onFocus() } .ifElse( hasFocus, Modifier.border( width = 2.dp, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), shape = MaterialTheme.shapes.medium ) ), onClick = onClick, colors = ClickableSurfaceDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = if (hasFocus) MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f) else Color.Transparent, pressedContainerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f) ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), scale = ClickableSurfaceDefaults.scale(scale = 1f, focusedScale = 1f) ) { Column( modifier = Modifier .fillMaxWidth() .padding(3.dp) ) { // 封面 Box( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) .clip(MaterialTheme.shapes.medium) ) { AsyncImage( model = data.cover.resizedImageUrl(ImageSize.LargeCover), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) // 底部渐变遮罩 Box( modifier = Modifier .fillMaxWidth() .height(60.dp) .align(Alignment.BottomCenter) .background( Brush.verticalGradient( colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.7f)) ) ) ) // 直播中标识(仅开播时显示) if (data.liveStatus == 1) { Box( modifier = Modifier .padding(8.dp) .background( color = Color(0xFFFF6699).copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp) ) .padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.TopStart) ) { Text( text = "直播中", color = Color.White, fontSize = 12.sp ) } } // 在线人数 Row( modifier = Modifier .padding(8.dp) .align(Alignment.BottomStart), verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier.size(16.dp), painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = Color.White ) Spacer(modifier = Modifier.width(4.dp)) Text( text = formatViewCount( data.watchedShow?.num ?: (data.online / 10) ), color = Color.White, fontSize = 13.sp ) } } Spacer(modifier = Modifier.height(8.dp)) // 直播间标题 Text( text = data.title, maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 4.dp) ) Spacer(modifier = Modifier.height(4.dp)) // 主播信息 Row( modifier = Modifier.padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically ) { // 主播头像 AsyncImage( model = data.face.resizedImageUrl(ImageSize.Icon), contentDescription = null, modifier = Modifier .size(20.dp) .clip(CircleShape), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.width(4.dp)) Text( text = data.uname, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } Spacer(modifier = Modifier.height(4.dp)) } } } /** * 格式化观看人数 */ private fun formatViewCount(count: Int): String { return when { count >= 100_000_000 -> String.format(Locale.US, "%.1f亿", count / 100_000_000.0) count >= 10_000 -> String.format(Locale.US, "%.1f万", count / 10_000.0) else -> count.toString() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/pgc/IndexFilter.kt ================================================ package dev.aaa1115910.bv.tv.component.pgc import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource 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.window.DialogProperties import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.FilterChip import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.pgc.PgcType import dev.aaa1115910.biliapi.entity.pgc.index.PGC_INDEX_ORDER_FIELD import dev.aaa1115910.biliapi.entity.pgc.index.PgcIndexOption import dev.aaa1115910.biliapi.entity.pgc.index.PgcIndexSection import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.getDisplayName @Composable fun IndexFilter( modifier: Modifier = Modifier, type: PgcType, show: Boolean, onDismissRequest: () -> Unit, sections: List, selectedFilters: Map, onFilterChange: (PgcIndexOption) -> Unit, onResetFilters: () -> Unit ) { val context = LocalContext.current IndexFilterContent( modifier = modifier, title = stringResource(R.string.pgc_index_filter_title_prefix) + type.getDisplayName(context), show = show, onDismissRequest = onDismissRequest, sections = sections, selectedFilters = selectedFilters, onFilterChange = onFilterChange, onResetFilters = onResetFilters ) } @Composable private fun IndexFilterContent( modifier: Modifier = Modifier, title: String, show: Boolean, onDismissRequest: () -> Unit, sections: List, selectedFilters: Map, onFilterChange: (PgcIndexOption) -> Unit, onResetFilters: () -> Unit ) { if (show) { TvAlertDialog( modifier = modifier .fillMaxWidth(0.8f), onDismissRequest = onDismissRequest, confirmButton = { if (sections.isNotEmpty()) { OutlinedButton(onClick = onResetFilters) { Text(text = stringResource(R.string.filter_dialog_reset)) } } }, title = { Text(text = title) }, text = { LazyColumn( modifier = Modifier.heightIn(max = 300.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { items( items = sections.filter { it.options.isNotEmpty() }, key = { section -> section.field } ) { section -> IndexFilterChipRow( title = section.title, options = section.options, selectedFilter = selectedFilters[section.field], onFilterChange = onFilterChange ) } } }, properties = DialogProperties(usePlatformDefaultWidth = false) ) } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun IndexFilterChip( modifier: Modifier = Modifier, selected: Boolean, onClick: () -> Unit, label: String ) { FilterChip( modifier = modifier, selected = selected, onClick = onClick ) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility(visible = selected) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Default.Check, contentDescription = null ) } Text(text = label) } } } @Composable private fun IndexFilterChipRow( modifier: Modifier = Modifier, title: String, options: List, selectedFilter: PgcIndexOption?, onFilterChange: (PgcIndexOption) -> Unit ) { val focusRequester = remember { FocusRequester() } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = title, style = MaterialTheme.typography.labelLarge ) LazyRow( modifier = modifier .focusRestorer(focusRequester), horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) ) { items( items = options, key = { option -> "${option.field}:${option.keyword}:${option.sort.orEmpty()}" } ) { option -> IndexFilterChip( modifier = if (selectedFilter == option) Modifier.focusRequester(focusRequester) else Modifier, selected = selectedFilter == option, onClick = { onFilterChange(option) }, label = option.name ) } } } } private class PgcTypeProvider : PreviewParameterProvider { override val values = PgcType.entries.asSequence() } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun IndexFilterPreview( @PreviewParameter(PgcTypeProvider::class) pgcType: PgcType ) { val sections = remember { listOf( PgcIndexSection( field = PGC_INDEX_ORDER_FIELD, title = "排序", options = listOf( PgcIndexOption(PGC_INDEX_ORDER_FIELD, "8", "综合排序", sort = "0"), PgcIndexOption(PGC_INDEX_ORDER_FIELD, "3", "最多追番", sort = "0"), PgcIndexOption(PGC_INDEX_ORDER_FIELD, "0", "最近更新", sort = "0") ) ), PgcIndexSection( field = "area", title = "地区", options = listOf( PgcIndexOption("area", "-1", "全部地区"), PgcIndexOption("area", "1,6,7", "国产"), PgcIndexOption("area", "2", "日本"), PgcIndexOption("area", "3", "美国") ) ), PgcIndexSection( field = "season_status", title = "付费类型", options = listOf( PgcIndexOption("season_status", "-1", "全部付费"), PgcIndexOption("season_status", "1", "免费"), PgcIndexOption("season_status", "2,6", "付费"), PgcIndexOption("season_status", "4,6", "大会员") ) ) ) } val selectedFilters = remember { mutableStateMapOf().apply { sections.forEach { section -> section.options.firstOrNull()?.let { option -> put(section.field, option) } } } } BVTheme { Surface( modifier = Modifier.fillMaxSize() ) { IndexFilter( type = pgcType, show = true, onDismissRequest = { }, sections = sections, selectedFilters = selectedFilters, onFilterChange = { option -> selectedFilters[option.field] = option }, onResetFilters = { selectedFilters.clear() sections.forEach { section -> section.options.firstOrNull()?.let { option -> selectedFilters[section.field] = option } } } ) } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/search/SearchKeyword.kt ================================================ package dev.aaa1115910.bv.tv.component.search import androidx.compose.foundation.Image import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier 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.tv.material3.DenseListItem import androidx.tv.material3.Text import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import coil.size.Size @Composable fun SearchKeyword( modifier: Modifier = Modifier, keyword: String, leadingIcon: String, trailingIcon: @Composable() (() -> Unit)? = null, onClick: () -> Unit ) { val context = LocalContext.current // 使用全局 ImageLoader,无需手动创建 val painter = rememberAsyncImagePainter( ImageRequest.Builder(context) .data(data = leadingIcon) .size(Size.ORIGINAL) .build(), contentScale = ContentScale.FillHeight ) if (leadingIcon != "" && painter.state is AsyncImagePainter.State.Success) { DenseListItem( modifier = modifier, selected = false, onClick = onClick, headlineContent = { Text( text = keyword, maxLines = 1, overflow = TextOverflow.Ellipsis ) }, leadingContent = { Image( modifier = Modifier.height(16.dp), painter = painter, contentDescription = null, ) }, trailingContent = trailingIcon ) } else { DenseListItem( modifier = modifier, selected = false, onClick = onClick, headlineContent = { Text( text = keyword, maxLines = 1, overflow = TextOverflow.Ellipsis ) }, trailingContent = trailingIcon ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/search/SoftKeyboard.kt ================================================ package dev.aaa1115910.bv.tv.component.search 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.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Checkbox import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun SoftKeyboard( modifier: Modifier = Modifier, firstButtonFocusRequester: FocusRequester, showSearchWithProxy: Boolean, enableSearchWithProxy: Boolean, onClick: (String) -> Unit, onClear: () -> Unit, onDelete: () -> Unit, onSearch: () -> Unit, onEnableSearchWithProxyChange: (Boolean) -> Unit ) { val keys = listOf( listOf("A", "B", "C", "D", "E", "F"), listOf("G", "H", "I", "J", "K", "L"), listOf("M", "N", "O", "P", "Q", "R"), listOf("S", "T", "U", "V", "W", "X"), listOf("Y", "Z", "1", "2", "3", "4"), listOf("5", "6", "7", "8", "9", "0") ) Column( modifier = modifier.width(258.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { Row( horizontalArrangement = Arrangement.spacedBy(6.dp) ) { SoftKeyboardButton( modifier = Modifier.weight(1f), key = stringResource(R.string.search_input_soft_keybord_clear), onClick = onClear ) SoftKeyboardButton( modifier = Modifier.weight(1f), key = stringResource(R.string.search_input_soft_keybord_delete), onClick = onDelete ) SoftKeyboardButton( modifier = Modifier.weight(1f), key = stringResource(R.string.search_input_soft_keybord_search), onClick = onSearch ) } if (showSearchWithProxy) { Surface( modifier = Modifier, onClick = { onEnableSearchWithProxyChange(!enableSearchWithProxy) }, colors = ClickableSurfaceDefaults.colors( focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ) ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Checkbox( checked = enableSearchWithProxy, onCheckedChange = { onEnableSearchWithProxyChange(it) }, ) Text(text = "通过代理搜索") } } } keys.forEachIndexed { rowIndex, rowKeys -> Row( horizontalArrangement = Arrangement.spacedBy(6.dp) ) { rowKeys.forEachIndexed { index, key -> val keyModifier = if (rowIndex == 0 && index == 0) { Modifier.focusRequester(firstButtonFocusRequester) } else { Modifier } SoftKeyboardKey( modifier = keyModifier, key = key, onClick = { onClick(key) } ) } } } } } @Composable fun SoftKeyboardKey( modifier: Modifier = Modifier, key: String, onClick: () -> Unit ) { Surface( modifier = modifier, onClick = onClick ) { Box( modifier = Modifier.size(38.dp), contentAlignment = Alignment.Center ) { Text( text = key, style = MaterialTheme.typography.titleMedium ) } } } @Composable fun SoftKeyboardButton( modifier: Modifier = Modifier, key: String, onClick: () -> Unit ) { Surface( modifier = modifier.height(38.dp), onClick = onClick ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = key, style = MaterialTheme.typography.titleMedium ) } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SoftKeyboardKeyPreview() { BVTheme { SoftKeyboardKey( key = "X", onClick = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SoftKeyboardPreview() { val firstButtonFocusRequester = remember { FocusRequester() } BVTheme { SoftKeyboard( firstButtonFocusRequester = firstButtonFocusRequester, showSearchWithProxy = true, enableSearchWithProxy = true, onClick = {}, onClear = {}, onDelete = {}, onSearch = {}, onEnableSearchWithProxyChange = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/settings/SettingListItem.kt ================================================ package dev.aaa1115910.bv.tv.component.settings import android.annotation.SuppressLint import android.content.Context import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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.focus.onFocusChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem import androidx.tv.material3.RadioButton import androidx.tv.material3.Text import dev.aaa1115910.bv.tv.component.TvAlertDialog @Composable fun SettingListItem( modifier: Modifier = Modifier, title: String, supportText: String, defaultHasFocus: Boolean = false, onClick: () -> Unit, valueText: String? = null ) { var hasFocus by remember { mutableStateOf(defaultHasFocus) } ListItem( modifier = modifier.onFocusChanged { hasFocus = it.hasFocus }, headlineContent = { Text(text = title) }, supportingContent = { Text(text = supportText) }, trailingContent = { if (valueText?.isNotEmpty() == true) Text(modifier = Modifier.padding(start = 4.dp), text = valueText) }, onClick = onClick, selected = false ) } @Composable fun SettingListItemWithDialog( modifier: Modifier = Modifier, title: String, supportText: String, options: List, getDisplayName: (T, Context) -> String, value: T, onValueChange: (T) -> Unit, defaultHasFocus: Boolean = false ) { val context = LocalContext.current var showDialog by remember { mutableStateOf(false) } SettingListItem( modifier = modifier, title = title, supportText = supportText, defaultHasFocus = defaultHasFocus, valueText = getDisplayName(value, context), onClick = { showDialog = true } ) SelectionDialog( show = showDialog, title = title, onHideDialog = { showDialog = false }, options = options, getDisplayName = getDisplayName, value = value, onChange = onValueChange ) } @SuppressLint("ConfigurationScreenWidthHeight") @Composable fun SelectionDialog( modifier: Modifier = Modifier, show: Boolean, title: String = "", options: List, getDisplayName: (T, Context) -> String, value: T, onChange: (T) -> Unit, onHideDialog: () -> Unit ) { if (show) { val context = LocalContext.current val configuration = LocalConfiguration.current val maxHeight = (configuration.screenHeightDp * 0.5).dp TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { if (title.isNotEmpty()) Text(text = title) }, text = { Column( modifier = Modifier .heightIn(max = maxHeight) .verticalScroll(rememberScrollState()) ) { options.forEach { ListItem( selected = value == it, onClick = { onChange(it) }, headlineContent = { Text(text = getDisplayName(it, context)) }, trailingContent = { RadioButton( selected = value == it, onClick = null ) } ) } } }, confirmButton = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/settings/SettingNumberListItem.kt ================================================ package dev.aaa1115910.bv.tv.component.settings import android.content.res.Configuration import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape 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.focus.onFocusChanged import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.height import androidx.compose.foundation.clickable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.rounded.ArrowDropUp import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.tv.material3.Icon import androidx.tv.material3.IconButton import androidx.compose.ui.Alignment import androidx.compose.ui.text.style.TextAlign import kotlin.math.round import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.tv.component.TvAlertDialog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import dev.aaa1115910.bv.util.requestFocus import kotlin.math.max import kotlin.math.min @Composable fun SettingNumberListItem( modifier: Modifier = Modifier, title: String, supportText: String, value: Double, minValue: Double = 0.0, maxValue: Double = 100.0, isInteger: Boolean = true, step: Double = 1.0, defaultHasFocus: Boolean = false, onValueChange: (Double) -> Unit ) { var hasFocus by remember { mutableStateOf(defaultHasFocus) } var currentValue by remember { mutableStateOf(value) } var showDialog by remember { mutableStateOf(false) } ListItem( modifier = modifier.onFocusChanged { hasFocus = it.hasFocus }, headlineContent = { Text(text = title) }, supportingContent = { Text(text = supportText) }, trailingContent = { Text( modifier = Modifier .widthIn(48.dp, 96.dp), text = if (isInteger) currentValue.toInt().toString() else String.format( "%.2f", currentValue ), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, color = if (hasFocus) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.onSurface ) }, onClick = { showDialog = true }, selected = hasFocus ) NumberDialog( show = showDialog, title = title, initValue = currentValue, minValue = minValue, maxValue = maxValue, step = step, isInteger = isInteger, onHideDialog = { showDialog = false }, onChange = { newValue -> currentValue = newValue.toDouble() onValueChange(currentValue) } ) } @Composable private fun NumberDialog( modifier: Modifier = Modifier, show: Boolean, title: String? = null, onHideDialog: () -> Unit, onChange: (Float) -> Unit, initValue: Double = 0.0, minValue: Double = 0.0, maxValue: Double = 100.0, step: Double = 1.0, isInteger: Boolean = true, ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } var currentValue by remember { mutableStateOf(initValue) } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = title ?: "") }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .fillMaxWidth() .onPreviewKeyEvent { if (it.key == Key.DirectionUp || it.key == Key.DirectionDown) { if (it.type == KeyEventType.KeyDown) { val newValue = if (it.key == Key.DirectionUp) (currentValue + step).coerceAtMost(maxValue) else (currentValue - step).coerceAtLeast(minValue) currentValue = if (isInteger) round(newValue) else newValue onChange(currentValue.toFloat()) } true } else if (listOf(Key.Enter, Key.DirectionCenter).contains(it.key) && it.type == KeyEventType.KeyDown) { onHideDialog() true } else { false } }, horizontalAlignment = Alignment.CenterHorizontally ) { Icon(imageVector = Icons.Rounded.ArrowDropUp, contentDescription = null) Text( text = if (isInteger) currentValue.toInt().toString() else String.format("%.2f", currentValue), style = MaterialTheme.typography.headlineMedium ) Icon(imageVector = Icons.Rounded.ArrowDropDown, contentDescription = null) } }, confirmButton = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SettingNumberListItemIntegerPreview() { BVTheme { SettingNumberListItem( title = "Integer Setting", supportText = "This is an integer value setting", value = 50.0, minValue = 0.0, maxValue = 100.0, isInteger = true, defaultHasFocus = true, onValueChange = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SettingNumberListItemDecimalPreview() { BVTheme { SettingNumberListItem( title = "Decimal Setting", supportText = "This is a decimal value setting", value = 2.25, minValue = 0.0, maxValue = 10.0, isInteger = false, step = 0.25, defaultHasFocus = true, onValueChange = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/settings/SettingSwitchListItem.kt ================================================ package dev.aaa1115910.bv.tv.component.settings import android.content.res.Configuration import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape 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.focus.onFocusChanged import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Switch import androidx.tv.material3.SwitchDefaults import androidx.tv.material3.Text import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun SettingSwitchListItem( modifier: Modifier = Modifier, title: String, supportText: String, checked: Boolean, defaultHasFocus: Boolean = false, onCheckedChange: (Boolean) -> Unit ) { var hasFocus by remember { mutableStateOf(defaultHasFocus) } var switchChecked by remember { mutableStateOf(checked) } ListItem( modifier = modifier.onFocusChanged { hasFocus = it.hasFocus }, headlineContent = { Text(text = title) }, supportingContent = { Text(text = supportText) }, trailingContent = { Box( modifier = Modifier .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape) ) { Switch( modifier = Modifier .focusable(false) .padding(2.dp), checked = switchChecked, onCheckedChange = null, colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.inverseSurface, checkedTrackColor = MaterialTheme.colorScheme.surfaceVariant, uncheckedThumbColor = MaterialTheme.colorScheme.onSurface, uncheckedTrackColor = MaterialTheme.colorScheme.surface ) ) } }, onClick = { switchChecked = !switchChecked onCheckedChange(switchChecked) }, selected = hasFocus ) } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SettingSwitchListItemFocusedAndEnabledPreview() { BVTheme { SettingSwitchListItem( title = "This is a title", supportText = "This is a support text", checked = true, defaultHasFocus = true, onCheckedChange = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SettingSwitchListItemFocusedAndDisabledPreview() { BVTheme { SettingSwitchListItem( title = "This is a title", supportText = "This is a support text", checked = false, defaultHasFocus = true, onCheckedChange = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SettingSwitchListItemNotFocusedAndEnabledPreview() { BVTheme { SettingSwitchListItem( title = "This is a title", supportText = "This is a support text", checked = true, defaultHasFocus = false, onCheckedChange = {} ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SettingSwitchListItemNotFocusedAndDisabledPreview() { BVTheme { SettingSwitchListItem( title = "This is a title", supportText = "This is a support text", checked = false, defaultHasFocus = false, onCheckedChange = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/settings/SettingsMenuSelectItem.kt ================================================ package dev.aaa1115910.bv.tv.component.settings import android.content.res.Configuration import androidx.compose.foundation.focusable 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.focus.onFocusChanged import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.RadioButton import androidx.tv.material3.RadioButtonDefaults import androidx.tv.material3.Text import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun SettingsMenuSelectItem( modifier: Modifier = Modifier, text: String, selected: Boolean, defaultHasFocus: Boolean = false, onClick: () -> Unit ) { var hasFocus by remember { mutableStateOf(defaultHasFocus) } ListItem( modifier = modifier.onFocusChanged { hasFocus = it.hasFocus }, headlineContent = { Text(text = text) }, trailingContent = { RadioButton( modifier = Modifier.focusable(false), selected = selected, onClick = { }, colors = RadioButtonDefaults.colors( selectedColor = if (hasFocus) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant, unselectedColor = if (hasFocus) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant ) ) }, onClick = onClick, selected = selected ) } private class SettingsMenuSelectItemPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( SettingsMenuSelectItemData(text = "This is a text", selected = false, onFocused = false), SettingsMenuSelectItemData(text = "This is a text", selected = false, onFocused = true), SettingsMenuSelectItemData(text = "This is a text", selected = true, onFocused = false), SettingsMenuSelectItemData(text = "This is a text", selected = true, onFocused = true), ) } private data class SettingsMenuSelectItemData( val text: String, val selected: Boolean, val onFocused: Boolean ) @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SettingsMenuSelectItemPreview( @PreviewParameter(SettingsMenuSelectItemPreviewParameterProvider::class) data: SettingsMenuSelectItemData ) { BVTheme { SettingsMenuSelectItem( text = data.text, selected = data.selected, defaultHasFocus = data.onFocused, onClick = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/settings/UpdateDialog.kt ================================================ package dev.aaa1115910.bv.tv.component.settings import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.tv.material3.Button import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text 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/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/videocard/LargeVideoCard.kt ================================================ package dev.aaa1115910.bv.tv.component.videocard import android.content.res.Configuration import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.border 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card 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.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.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView 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.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.tv.component.UpIcon import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.focusedBorder @Composable fun LargeVideoCard( modifier: Modifier = Modifier, data: VideoCardData, onClick: () -> Unit = {}, onFocus: () -> Unit = {} ) { val view = LocalView.current var hasFocus by remember { mutableStateOf(false) } val scale by animateFloatAsState( targetValue = if (hasFocus) 1f else 0.95f, label = "large video card scale" ) val height = 160.dp val reasonColor = Color.Red LaunchedEffect(hasFocus) { if (hasFocus) onFocus() } Card( modifier = modifier .fillMaxWidth() .scale(scale) .onFocusChanged { hasFocus = it.isFocused } .focusedBorder(MaterialTheme.shapes.medium) .clickable { onClick() }, shape = MaterialTheme.shapes.medium ) { Row( modifier = Modifier .height(height) ) { Box { if (!view.isInEditMode) { AsyncImage( modifier = Modifier .fillMaxHeight() .aspectRatio(1.6f) .clip(MaterialTheme.shapes.medium), model = data.cover, contentDescription = null, contentScale = ContentScale.FillBounds ) } else { Surface( modifier = Modifier .fillMaxHeight() .aspectRatio(1.6f), shape = MaterialTheme.shapes.medium, colors = SurfaceDefaults.colors( containerColor = Color.White ) ) {} } Surface( modifier = Modifier .align(Alignment.BottomEnd) .padding(8.dp), colors = SurfaceDefaults.colors( containerColor = Color.Black.copy(alpha = 0.5f) ), shape = RoundedCornerShape(6.dp) ) { Text( modifier = Modifier.padding(4.dp), text = data.timeString, style = MaterialTheme.typography.bodySmall, color = Color.White ) } } Column( modifier = Modifier .fillMaxHeight() .padding(8.dp), verticalArrangement = Arrangement.SpaceBetween ) { Text( text = data.title, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge ) Box( modifier = Modifier .padding(start = 4.dp) .border( width = (1.5).dp, color = if (data.reason.isNotEmpty()) reasonColor else Color.Transparent, shape = RoundedCornerShape(6.dp) ) ) { Text( modifier = Modifier.padding(6.dp, 2.dp), text = data.reason, style = MaterialTheme.typography.bodySmall, color = reasonColor, fontWeight = FontWeight.Bold ) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { UpIcon() Text(text = data.upName) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start) ) { Text(text = "P${data.playString}") Text(text = "D${data.danmakuString}") } } } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun LargeVideoCardPreview() { val data = VideoCardData( avid = 0, title = "震惊!太震惊了!真的是太震惊了!我的天呐!真TMD震惊!", cover = "http://i2.hdslb.com/bfs/archive/af17fc07b8f735e822563cc45b7b5607a491dfff.jpg", reason = "本周必看", upName = "bishi", play = 2333, danmaku = 666, time = 2333 * 1000 ) BVTheme { Surface { LargeVideoCard( data = data ) } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/videocard/SeasonCard.kt ================================================ package dev.aaa1115910.bv.tv.component.videocard import android.content.res.Configuration import androidx.compose.foundation.BorderStroke 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.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.focus.onFocusChanged 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 androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.ui.theme.BVTheme @Composable fun SeasonCard( modifier: Modifier = Modifier, data: SeasonCardData, coverHeight: Dp? = null, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, onFocus: () -> Unit = {} ) { val localDensity = LocalDensity.current var coverRealWidth by remember { mutableStateOf(0.dp) } Surface( modifier = modifier .onFocusChanged { if (it.hasFocus) onFocus() }, onClick = onClick, onLongClick = onLongClick, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surface, focusedContainerColor = MaterialTheme.colorScheme.surface, pressedContainerColor = MaterialTheme.colorScheme.surface ), tonalElevation = 8.dp, shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), scale = ClickableSurfaceDefaults.scale(scale = 1f, focusedScale = 1.04f), border = ClickableSurfaceDefaults.border( border = Border( BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.border.copy(0.05f)) ), focusedBorder = Border( border = BorderStroke(width = 3.dp, color = MaterialTheme.colorScheme.border), shape = MaterialTheme.shapes.medium ) ) ) { Column { val coverModifier = if (coverHeight != null) { Modifier.height(coverHeight) } else { Modifier.fillMaxWidth() } val textBoxModifier = if (coverHeight != null) { Modifier.width((0.765 * coverHeight.value).dp) } else { Modifier } Box( contentAlignment = Alignment.BottomCenter ) { AsyncImage( modifier = coverModifier .aspectRatio(0.765f) .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, 6.dp, 8.dp, 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") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SeasonCardPreview() { BVTheme { 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/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/videocard/SmallVideoCard.kt ================================================ package dev.aaa1115910.bv.tv.component.videocard import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.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.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.focus.onFocusChanged 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 androidx.compose.ui.unit.sp import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.tv.component.UpIcon import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.resizedImageUrl @Composable fun SmallVideoCard( modifier: Modifier = Modifier, data: VideoCardData, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, onFocus: () -> Unit = {}, initialFocus: Boolean = false, unfocusedBorderColor: Color? = null ) { var hasFocus by remember { mutableStateOf(initialFocus) } Surface( modifier = modifier .fillMaxWidth() .onFocusChanged { hasFocus = it.isFocused if (hasFocus) onFocus() } .ifElse( hasFocus, Modifier.border( width = 2.dp, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), shape = MaterialTheme.shapes.medium ) ) .then( if (!hasFocus && unfocusedBorderColor != null) { Modifier.border( width = 2.dp, color = unfocusedBorderColor, shape = MaterialTheme.shapes.medium ) } else { Modifier } ), onClick = onClick, onLongClick = onLongClick, colors = ClickableSurfaceDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = if (hasFocus) MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f) else Color.Transparent, pressedContainerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f) ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), scale = ClickableSurfaceDefaults.scale(scale = 1f, focusedScale = 1f) ) { Column( modifier = modifier .fillMaxWidth() .padding(top = 3.dp, start = 3.dp, end = 3.dp), ) { CardCover( modifier = Modifier .clip(MaterialTheme.shapes.medium), cover = data.cover, play = data.playString, danmaku = data.danmakuString, time = data.timeString, badge = "${if(data.isChargingArc) "⚡" else ""}${if(data.badgeText.isEmpty() && data.isChargingArc) "充电专属" else data.badgeText}" ) Spacer(modifier = Modifier.height(8.dp)) CardInfo( modifier = Modifier .fillMaxWidth() .height(79.dp) .padding(horizontal = 1.dp), title = data.title, upName = data.upName, pubTime = data.pubTime ) } } } @Composable private fun CoverBottomInfo( modifier: Modifier = Modifier, play: String, danmaku: String, time: String ) { Row( modifier = modifier .fillMaxWidth() .padding(10.dp, 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (play.isNotBlank()) { Icon( painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = Color.White ) Spacer(Modifier.width(2.dp)) Text( text = play, style = MaterialTheme.typography.bodySmall, color = Color.White ) } if (danmaku.isNotBlank()) { if (play.isNotBlank()) Spacer(Modifier.width(8.dp)) Icon( painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = Color.White ) Spacer(Modifier.width(2.dp)) Text( text = danmaku, style = MaterialTheme.typography.bodySmall, color = Color.White ) } Spacer(Modifier.weight(1f)) Text( text = time, style = MaterialTheme.typography.bodySmall, color = Color.White, maxLines = 1 ) } } @Composable fun CardCover( modifier: Modifier = Modifier, cover: String, play: String, danmaku: String, time: String, badge: String = "" ) { BoxWithConstraints( modifier = modifier, contentAlignment = Alignment.BottomCenter ) { val showInfo = maxWidth > 160.dp AsyncImage( modifier = Modifier .fillMaxWidth() .aspectRatio(1.6f), model = cover.resizedImageUrl(ImageSize.SmallVideoCardCover), contentDescription = null, contentScale = ContentScale.Crop ) // 封面与徽章叠放,徽章绝对定位在右上角 if (badge.isNotEmpty()) { Text( modifier = Modifier .padding(5.dp) .align(Alignment.TopEnd) .background( color = Color.Black.copy(0.3f), shape = MaterialTheme.shapes.extraSmall ) .padding(vertical = 1.dp, horizontal = 2.dp), text = badge, style = MaterialTheme.typography.bodySmall, color = Color.White ) } // 只有需要显示时才创建阴影和信息组件 if (showInfo) { Box( modifier = Modifier .fillMaxWidth() .height(48.dp) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.8f) ) ) ) ) CoverBottomInfo( play = play, danmaku = danmaku, time = time ) } } } @Composable private fun CardInfo( modifier: Modifier = Modifier, title: String, upName: String, pubTime: String? ) { Column(modifier = modifier) { Text( modifier = Modifier, text = title, style = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp), maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier .fillMaxWidth() .height(24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { if (upName.isNotEmpty()) { UpIcon() Text( modifier = Modifier .weight(1f) .padding(end = 2.dp), text = upName, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) } pubTime?.let { Text( text = it, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Visible ) } } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SmallVideoCardWithoutFocusPreview() { val data = VideoCardData( avid = 0, title = "震惊!太震惊了!真的是太震惊了!我的天呐!真TMD震惊!", cover = "http://i2.hdslb.com/bfs/archive/af17fc07b8f735e822563cc45b7b5607a491dfff.jpg", upName = "震惊!太震惊了!真的是太震惊了!我的天呐!真TMD震惊!", play = 2333, danmaku = 666, time = 2333 * 1000, pubTime = "3小时前", isChargingArc = true ) BVTheme { Surface( modifier = Modifier.width(300.dp) ) { SmallVideoCard( modifier = Modifier.padding(20.dp), data = data, initialFocus = false ) } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SmallVideoCardWithFocusPreview() { 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, pubTime = "3小时前" ) BVTheme { Surface( modifier = Modifier.width(300.dp) ) { SmallVideoCard( modifier = Modifier.padding(20.dp), data = data, initialFocus = true ) } } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SmallVideoCardsPreview() { val data = VideoCardData( avid = 0, title = "震惊!太震惊了!真的是太震惊了!我的天呐!真TMD震惊!", //cover = "http://i2.hdslb.com/bfs/archive/af17fc07b8f735e822563cc45b7b5607a491dfff.jpg", cover = "", upName = "bishi", play = 2333, danmaku = 666, time = 2333 * 1000, pubTime = "3小时前" ) BVTheme { LazyVerticalGrid( columns = GridCells.Fixed(4), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { repeat(20) { item { SmallVideoCard( data = data ) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/videocard/TabbedVideosPanel.kt ================================================ package dev.aaa1115910.bv.tv.component.videocard import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background 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.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Tab import androidx.tv.material3.TabDefaults import androidx.tv.material3.TabRow import androidx.tv.material3.Text import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.tv.util.stableItemKey @Composable fun TabbedVideosPanel( modifier: Modifier = Modifier, relatedVideos: List, preloadedVideos: List, currentAid: Long, focusRequester: FocusRequester, onOpenSeasonInfo: (VideoCardData, Boolean) -> Unit = { _, _ -> }, onOpenVideoInfo: (VideoCardData, Boolean) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val density = LocalDensity.current var selectedTabIndex by remember { mutableIntStateOf(0) } var hasFocus by remember { mutableStateOf(false) } val titleFontSize by animateFloatAsState( targetValue = 24f, label = "title font size", animationSpec = tween(durationMillis = 120) ) var rowHeight by remember { mutableStateOf(0.dp) } // Build tabs: always show "推荐视频", show "视频列表" only when preloaded is not empty val tabs = remember(relatedVideos.size, preloadedVideos.size) { buildList { add("推荐视频" to relatedVideos) if (preloadedVideos.isNotEmpty()) { add("UGC视频列表" to preloadedVideos) } } } // Clamp selectedTabIndex LaunchedEffect(tabs.size) { if (selectedTabIndex >= tabs.size) selectedTabIndex = 0 } val currentVideos = tabs.getOrNull(selectedTabIndex)?.second ?: emptyList() // Find index of current video in the preloaded list tab val currentVideoIndexInPreloaded = remember(preloadedVideos, currentAid) { preloadedVideos.indexOfFirst { it.avid == currentAid } } val relatedListState = rememberLazyListState() val preloadedListState = rememberLazyListState() // When switching to "视频列表" tab and current video is in the list, scroll to it val isPreloadedTab = tabs.size > 1 && selectedTabIndex == 1 val lazyListState = if (isPreloadedTab) preloadedListState else relatedListState LaunchedEffect(selectedTabIndex) { if (isPreloadedTab && currentVideoIndexInPreloaded >= 0) { preloadedListState.scrollToItem(currentVideoIndexInPreloaded) } } val listFocusRestorer = rememberTvLazyListFocusRestorer(focusRequester) val onLongClickVideo: (VideoCardData) -> Unit = { videoCard -> if (videoCard.upId > 0) UpInfoActivity.actionStart( context, mid = videoCard.upId, name = videoCard.upName, face = videoCard.upFace ) } Column( modifier = modifier .onFocusChanged { hasFocus = it.hasFocus } .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.7f) ) ) ) ) { // Tab row - only show if more than one tab if (tabs.size > 1) { TabRow( modifier = Modifier.padding(start = 36.dp, top = 3.dp, bottom = 3.dp), selectedTabIndex = selectedTabIndex, separator = { Text(" ") } ) { tabs.forEachIndexed { index, (title, _) -> Tab( selected = selectedTabIndex == index, onFocus = { selectedTabIndex = index }, onClick = { selectedTabIndex = index }, colors = TabDefaults.pillIndicatorTabColors(), ) { Text( text = title, fontSize = titleFontSize.sp, modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp) ) } } } } else { Text( modifier = Modifier.padding(start = 36.dp, top = 3.dp, bottom = 3.dp), text = tabs.firstOrNull()?.first ?: "", fontSize = titleFontSize.sp ) } LazyRow( modifier = listFocusRestorer.containerModifier( Modifier .padding(vertical = 15.dp) .onGloballyPositioned { rowHeight = with(density) { it.size.height.toDp() } } ), state = lazyListState, horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 36.dp) ) { itemsIndexed( items = currentVideos, key = { index, videoData -> "${selectedTabIndex}-${index}-${videoData.stableItemKey()}" } ) { index, videoData -> val isCurrentVideo = isPreloadedTab && index == currentVideoIndexInPreloaded SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index, Modifier.width(200.dp)), data = videoData, unfocusedBorderColor = if (isCurrentVideo) MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) else null, onClick = { val fromUGCList = selectedTabIndex == 1 if (videoData.jumpToSeason) { onOpenSeasonInfo(videoData, fromUGCList) } else { onOpenVideoInfo(videoData, fromUGCList) } }, onLongClick = { onLongClickVideo(videoData) } ) } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/component/videocard/VideosRow.kt ================================================ package dev.aaa1115910.bv.tv.component.videocard import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.tv.util.stableItemKey import dev.aaa1115910.bv.util.ifElse @Composable fun VideosRow( modifier: Modifier = Modifier, header: String, hideShowMore: Boolean = true, videos: List, showMore: () -> Unit, onOpenSeasonInfo: (VideoCardData) -> Unit = {}, onOpenVideoInfo: (VideoCardData) -> Unit = {}, focusRequester: FocusRequester? = null // 渲染为 播放器-推荐视频 时有值 ) { val context = LocalContext.current val density = LocalDensity.current val internalFocusRequester = remember { FocusRequester() } val activeFocusRequester = focusRequester ?: internalFocusRequester val listFocusRestorer = rememberTvLazyListFocusRestorer(activeFocusRequester) var hasFocus by remember { mutableStateOf(false) } val titleFontSize by animateFloatAsState( targetValue = if (focusRequester != null) 24f else if (hasFocus) 30f else 14f, label = "title font size", animationSpec = tween( durationMillis = 120 ) ) var rowHeight by remember { mutableStateOf(0.dp) } val onLongClickVideo: (VideoCardData) -> Unit = { videoCard -> if (videoCard.upId > 0) UpInfoActivity.actionStart( context, mid = videoCard.upId, name = videoCard.upName, face = videoCard.upFace ) } Column( modifier = modifier .onFocusChanged { hasFocus = it.hasFocus } .ifElse(focusRequester != null, Modifier.background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.7f) ) ) )) ) { Text( modifier = Modifier.padding(start = 36.dp, top = 3.dp, bottom = 3.dp), text = header, fontSize = titleFontSize.sp ) LazyRow( modifier = listFocusRestorer.containerModifier( Modifier .padding(vertical = 15.dp) .onGloballyPositioned { rowHeight = with(density) { it.size.height.toDp() } } ), horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 36.dp) ) { itemsIndexed( items = videos, key = { index, videoData -> "$index-${videoData.stableItemKey()}" } ) { index, videoData -> SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index, Modifier.width(200.dp)), data = videoData, onClick = { if (videoData.jumpToSeason) { onOpenSeasonInfo(videoData) } else { onOpenVideoInfo(videoData) } }, onLongClick={onLongClickVideo(videoData)} ) } if (!hideShowMore) { item { Button( modifier = Modifier.height(rowHeight), shape = ButtonDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = showMore ) { Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center, ) { Text(text = "显示更多") } } } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/manager/FollowStateManager.kt ================================================ package dev.aaa1115910.bv.tv.manager import dev.aaa1115910.biliapi.repositories.UserRepository import dev.aaa1115910.bv.util.Prefs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.java.KoinJavaComponent.get import java.util.concurrent.ConcurrentHashMap /** * 关注状态管理器,用于在不同页面间同步用户关注状态 * 避免重复调用API获取关注状态 */ object FollowStateManager { // 存储用户关注状态的Map,key为用户mid,value为关注状态 private val _followStateMap = MutableStateFlow>(emptyMap()) val followStateMap: StateFlow> = _followStateMap.asStateFlow() // 每个 mid 一把锁,保证同一个 mid 只会有一个在途请求 private val fetchLocks = ConcurrentHashMap() private val userRepository: UserRepository by lazy { get(UserRepository::class.java) } /** * 获取指定用户的关注状态 * @param mid 用户mid * @return 关注状态,null表示未知状态(需要调用API获取) */ fun getFollowState(mid: Long): Boolean? { return _followStateMap.value[mid] } /** * 获取或请求指定用户的关注状态,自动去重。 * 多个调用方对同一 mid 并发调用时,只有第一个会执行 API 请求, * 后续调用方等待锁释放后直接读取缓存。 */ suspend fun ensureFollowState(mid: Long): Boolean? { if (mid <= 0) return null getFollowState(mid)?.let { return it } val lock = fetchLocks.getOrPut(mid) { Mutex() } return lock.withLock { // 拿到锁后再查一次缓存,前一个持锁者可能已经写入 getFollowState(mid)?.let { return@withLock it } val result = runCatching { userRepository.checkIsFollowing( mid = mid, preferApiType = Prefs.apiType ) }.getOrNull() if (result != null) { updateFollowState(mid, result) } result } } /** * 更新用户关注状态 * @param mid 用户mid * @param isFollowing 是否关注 */ fun updateFollowState(mid: Long, isFollowing: Boolean) { _followStateMap.value = _followStateMap.value.toMutableMap().apply { this[mid] = isFollowing } } /** * 移除指定用户的关注状态缓存 * @param mid 用户mid */ fun removeFollowState(mid: Long) { _followStateMap.value = _followStateMap.value.toMutableMap().apply { remove(mid) } } /** * 清空所有关注状态缓存 */ fun clearAll() { _followStateMap.value = emptyMap() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/manager/PlayedAidsCache.kt ================================================ package dev.aaa1115910.bv.tv.manager import java.util.concurrent.ConcurrentHashMap /** * Application 级播放过的稿件 aid 缓存。 * 用途:避免自动播放推荐时出现重复稿件;在退出播放器或应用重启时清空。 */ object PlayedAidsCache { // 使用线程安全集合,保证多协程访问安全 private val playedAids = ConcurrentHashMap.newKeySet() /** 标记已播放 */ fun markPlayed(aid: Long) { if (aid > 0) playedAids.add(aid) } /** 是否已经播放过 */ fun hasPlayed(aid: Long): Boolean = aid > 0 && playedAids.contains(aid) /** 返回所有已播放 aid 快照 */ fun all(): Set = playedAids.toSet() /** 清空缓存 */ fun clear() { playedAids.clear() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/manager/VideoUserActionManager.kt ================================================ package dev.aaa1115910.bv.tv.manager import dev.aaa1115910.biliapi.entity.FavoriteFolderMetadata import dev.aaa1115910.biliapi.repositories.CoinRepository import dev.aaa1115910.biliapi.repositories.FavoriteRepository import dev.aaa1115910.biliapi.repositories.LikeRepository import dev.aaa1115910.biliapi.repositories.ToViewRepository import dev.aaa1115910.bv.util.Prefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.koin.java.KoinJavaComponent.get import java.util.concurrent.ConcurrentHashMap data class VideoActionState( val liked: Boolean = false, val favorited: Boolean = false, val coin: Boolean = false, val favoriteFolderIds: List = emptyList() ) /** * Simple in-memory manager for video user actions (like/favorite/coin). * Keyed by aid. Exposes a StateFlow per aid so UI can collect and share state across screens. * Network operations delegate to repositories from Koin and update the corresponding flow on success. */ object VideoUserActionManager { // key = Pair(uid, aid) private val stateMap = ConcurrentHashMap, MutableStateFlow>() // key = uid, favorite folders are user-global private val favoriteFoldersMap = ConcurrentHashMap>>() private val fetchMutexMap = ConcurrentHashMap() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private fun key(uid: Long, aid: Long) = uid to aid private fun ensure(aid: Long, uid: Long = Prefs.uid): MutableStateFlow { val k = key(uid, aid) stateMap[k]?.let { return it } val newFlow = MutableStateFlow(VideoActionState()) return stateMap.putIfAbsent(k, newFlow) ?: newFlow } fun getStateFlow(aid: Long, uid: Long = Prefs.uid): StateFlow = ensure(aid, uid) private fun ensureFavoriteFolders(uid: Long = Prefs.uid): MutableStateFlow> { val flow = favoriteFoldersMap.getOrPut(uid) { MutableStateFlow(emptyList()) } if (flow.value.isEmpty()) { scope.launch { val mutex = fetchMutexMap.getOrPut(uid) { Mutex() } mutex.withLock { if (flow.value.isNotEmpty()) return@launch runCatching { flow.value = get(FavoriteRepository::class.java) .getAllFavoriteFolderMetadataList(mid = uid, preferApiType = Prefs.apiType) } } } } return flow } fun getFavoriteFoldersFlow(uid: Long = Prefs.uid): StateFlow> = ensureFavoriteFolders(uid) suspend fun updateFromLoadedData(aid: Long, liked: Boolean, favorited: Boolean, coin: Boolean, uid: Long = Prefs.uid) { val flow = ensure(aid, uid) flow.value = flow.value.copy(liked = liked, favorited = favorited, coin = coin) // load favorite folder ids for this video if (aid <= 0 || !Prefs.isLogin) return val favoriteRepository: FavoriteRepository = get(FavoriteRepository::class.java) val mutex = fetchMutexMap.getOrPut(uid) { Mutex() } mutex.withLock { runCatching { val folders = withContext(Dispatchers.IO) { favoriteRepository.getAllFavoriteFolderMetadataList( mid = uid, rid = aid, preferApiType = Prefs.apiType ) } // update folder list cache (superset of the no-rid call) favoriteFoldersMap.getOrPut(uid) { MutableStateFlow(emptyList()) }.value = folders // update video action state with folder ids val folderIds = folders.filter { it.videoInThisFav }.map { it.id } flow.value = flow.value.copy(favoriteFolderIds = folderIds) } } } suspend fun addLike(aid: Long, uid: Long = Prefs.uid): Boolean { if (aid <= 0) return false val likeRepository: LikeRepository = get(LikeRepository::class.java) return try { withContext(Dispatchers.IO) { likeRepository.addVideoLike(aid = aid) } ensure(aid, uid).value = ensure(aid, uid).value.copy(liked = true) true } catch (_: Exception) { false } } suspend fun delLike(aid: Long, uid: Long = Prefs.uid): Boolean { if (aid <= 0) return false val likeRepository: LikeRepository = get(LikeRepository::class.java) return try { withContext(Dispatchers.IO) { likeRepository.delVideoLike(aid = aid) } ensure(aid, uid).value = ensure(aid, uid).value.copy(liked = false) true } catch (_: Exception) { false } } suspend fun addCoin(aid: Long, uid: Long = Prefs.uid): Boolean { if (aid <= 0) return false val coinRepository: CoinRepository = get(CoinRepository::class.java) return try { withContext(Dispatchers.IO) { coinRepository.addVideoCoin(aid = aid) } ensure(aid, uid).value = ensure(aid, uid).value.copy(coin = true) true } catch (_: Exception) { false } } suspend fun addToView(aid: Long, uid: Long = Prefs.uid): Boolean { if (aid <= 0 || uid <= 0) return false val toViewRepository: ToViewRepository = get(ToViewRepository::class.java) return try { withContext(Dispatchers.IO) { toViewRepository.addToView( avid = aid, preferApiType = Prefs.apiType ) } } catch (_: Exception) { false } } suspend fun updateVideoFavoriteFolders(aid: Long, folderIds: List, uid: Long = Prefs.uid): Boolean { if (aid <= 0) return false val favoriteRepository: FavoriteRepository = get(FavoriteRepository::class.java) val currentFolders = ensureFavoriteFolders(uid).value return try { withContext(Dispatchers.IO) { require(currentFolders.isNotEmpty()) favoriteRepository.updateVideoToFavoriteFolder( aid = aid, addMediaIds = folderIds, delMediaIds = currentFolders.map { it.id } - folderIds.toSet() ) } ensure(aid, uid).value = ensure(aid, uid).value.copy( favoriteFolderIds = folderIds, favorited = folderIds.isNotEmpty() ) true } catch (_: Exception) { false } } suspend fun delVideoFromFavoriteFolder(aid: Long, folderId: Long, uid: Long = Prefs.uid): Boolean { if (aid <= 0) return false val favoriteRepository: FavoriteRepository = get(FavoriteRepository::class.java) return try { withContext(Dispatchers.IO) { favoriteRepository.delVideoFromFavoriteFolder( aid = aid, delMediaIds = listOf(folderId), preferApiType = Prefs.apiType ) } val flow = ensure(aid, uid) val updatedIds = flow.value.favoriteFolderIds - folderId flow.value = flow.value.copy( favoriteFolderIds = updatedIds, favorited = updatedIds.isNotEmpty() ) true } catch (_: Exception) { false } } suspend fun addToDefaultFavoriteFolder(aid: Long, uid: Long = Prefs.uid): Boolean { if (aid <= 0) return false val flow = ensure(aid, uid) val default = ensureFavoriteFolders(uid).value.firstOrNull { it.title == "默认收藏夹" } ?: return false return updateVideoFavoriteFolders(aid, listOf(default.id), uid) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/MainScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens import android.app.Activity import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility 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.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.NavSwitchMode import dev.aaa1115910.bv.tv.component.UserPanel import dev.aaa1115910.bv.tv.activities.settings.SettingsActivity import dev.aaa1115910.bv.tv.activities.user.FavoriteActivity import dev.aaa1115910.bv.tv.activities.user.FollowingSeasonActivity import dev.aaa1115910.bv.tv.activities.user.HistoryActivity import dev.aaa1115910.bv.tv.activities.user.LoginActivity import dev.aaa1115910.bv.tv.activities.user.ToViewActivity import dev.aaa1115910.bv.tv.activities.user.UserInfoActivity import dev.aaa1115910.bv.tv.screens.main.DrawerContent import dev.aaa1115910.bv.tv.screens.main.DrawerItem import dev.aaa1115910.bv.tv.screens.main.HomeContent import dev.aaa1115910.bv.tv.screens.main.LiveContent import dev.aaa1115910.bv.tv.screens.main.PgcContent import dev.aaa1115910.bv.tv.screens.main.UgcContent import dev.aaa1115910.bv.tv.screens.main.currentSelectedTabs import dev.aaa1115910.bv.tv.screens.main.drawerItemFocusRequesters import dev.aaa1115910.bv.tv.screens.search.SearchInputScreen import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fException import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.UserViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.delay import org.koin.androidx.compose.koinViewModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @Composable fun MainScreen( modifier: Modifier = Modifier, userViewModel: UserViewModel = koinViewModel() ) { val context = LocalContext.current val logger = KotlinLogging.logger("MainScreen") val scope = rememberCoroutineScope() var showUserPanel by remember { mutableStateOf(false) } var lastPressBack: Long by remember { mutableLongStateOf(0L) } var selectedDrawerItem by remember { mutableStateOf(DrawerItem.Home) } var focusedDrawerItem by remember { mutableStateOf(DrawerItem.Home) } val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) val mainFocusRequester = remember { FocusRequester() } val ugcFocusRequester = remember { FocusRequester() } val pgcFocusRequester = remember { FocusRequester() } val liveFocusRequester = remember { FocusRequester() } val searchFocusRequester = remember { FocusRequester() } // 时间显示状态 var currentTime by remember { val dateFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) mutableStateOf(dateFormat.format(Date())) } // 定时更新时间 LaunchedEffect(Unit) { val dateFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) while (true) { delay(60_000L - System.currentTimeMillis() % 60_000L) currentTime = dateFormat.format(Date()) } } val handleBack = { val currentTime = System.currentTimeMillis() if (currentTime - lastPressBack < 1500) { logger.fInfo { "Exiting bug video" } currentSelectedTabs[DrawerItem.Home] = Prefs.defaultHomeTab (context as Activity).finish() } else { lastPressBack = currentTime R.string.home_press_back_again_to_exit.toast(context) } } val onFocusToContent: () -> Unit = { when (selectedDrawerItem) { DrawerItem.Home -> mainFocusRequester.requestFocus() DrawerItem.UGC -> ugcFocusRequester.requestFocus() DrawerItem.PGC -> pgcFocusRequester.requestFocus() DrawerItem.Live -> liveFocusRequester.requestFocus() DrawerItem.Search -> searchFocusRequester.requestFocus() else -> { // 用户+设置等非内容页,回到当前选中内容的菜单项再进入内容 drawerItemFocusRequesters[selectedDrawerItem]?.requestFocus() } } } LaunchedEffect(Unit) { runCatching { mainFocusRequester.requestFocus() }.onFailure { logger.fException(it) { "request default focus requester failed" } } } BackHandler { handleBack() } Scaffold(modifier = modifier) { contentPadding -> Box( modifier = Modifier .fillMaxSize() .padding(contentPadding) ) { val borderColor = MaterialTheme.colorScheme.surfaceContainerHigh val borderWidth = 1.dp // Left side - NavigationRail NavigationRail( modifier = Modifier .align(Alignment.CenterStart) .width(71.dp) .padding(end = borderWidth) .drawBehind { val borderWidthPx = borderWidth.toPx() val x = size.width + borderWidthPx drawLine( color = borderColor, start = Offset(x = x, y = 0f), end = Offset(x = x, y = size.height), strokeWidth = borderWidthPx ) }, ) { DrawerContent( modifier = Modifier.fillMaxWidth(), isLogin = userViewModel.isLogin, avatar = userViewModel.face, username = userViewModel.username, navSwitchMode = navSwitchMode, //avatar = "https://i2.hdslb.com/bfs/face/ef0457addb24141e15dfac6fbf45293ccf1e32ab.jpg", //username = "碧诗", onDrawerItemChanged = { selectedDrawerItem = it }, onDrawerItemfocused = { focusedDrawerItem = it }, onOpenSettings = { context.startActivity(Intent(context, SettingsActivity::class.java)) }, onShowUserPanel = { // showUserPanel = true context.startActivity(Intent(context, UserInfoActivity::class.java)) }, onFocusToContent = onFocusToContent, onLogin = { context.startActivity(Intent(context, LoginActivity::class.java)) } ) } // Right side - NavHost content AnimatedContent( modifier = Modifier .fillMaxSize() .padding(start = 72.dp), targetState = selectedDrawerItem, label = "main animated content", transitionSpec = { val coefficient = 20 if (targetState.ordinal < initialState.ordinal) { slideInVertically { -it / coefficient } togetherWith fadeOut(animationSpec = tween(200)) + slideOutVertically { it / coefficient } } else { slideInVertically { it / coefficient } togetherWith fadeOut(animationSpec = tween(200)) + slideOutVertically { -it / coefficient } } } ) { screen -> when (screen) { DrawerItem.Home -> HomeContent(navFocusRequester = mainFocusRequester) DrawerItem.UGC -> UgcContent(navFocusRequester = ugcFocusRequester) DrawerItem.PGC -> PgcContent(navFocusRequester = pgcFocusRequester) DrawerItem.Live -> LiveContent(navFocusRequester = liveFocusRequester) DrawerItem.Search -> SearchInputScreen(defaultFocusRequester = searchFocusRequester) else -> {} } } // 右上角时间显示 Text( text = currentTime, modifier = Modifier .align(Alignment.TopEnd) .padding(end = 8.dp, top = 0.dp) .offset(y=(-2).dp), style = MaterialTheme.typography.titleMedium.copy( fontSize = 13.sp ), color = MaterialTheme.colorScheme.onSurface ) } AnimatedVisibility( visible = showUserPanel, enter = fadeIn(), exit = fadeOut() ) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.6f)) ) { AnimatedVisibility( modifier = Modifier .align(Alignment.Center), visible = showUserPanel, enter = fadeIn() + scaleIn(), exit = fadeOut() ) { UserPanel( modifier = Modifier .padding(12.dp), username = userViewModel.username, face = userViewModel.face, onHide = { showUserPanel = false }, onGoMy = { context.startActivity(Intent(context, UserInfoActivity::class.java)) }, onGoHistory = { context.startActivity(Intent(context, HistoryActivity::class.java)) }, onGoFavorite = { context.startActivity(Intent(context, FavoriteActivity::class.java)) }, onGoFollowing = { context.startActivity( Intent( context, FollowingSeasonActivity::class.java ) ) }, onGoLater = { context.startActivity(Intent(context, ToViewActivity::class.java)) } ) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/RegionBlockScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens import android.app.Activity import android.content.res.Configuration import android.graphics.BitmapFactory import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.keyframes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.focusable 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmapConfig import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.key.onKeyEvent 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 androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme import okhttp3.internal.toHexString import qrcode.QRCode import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import kotlin.system.exitProcess @Composable fun RegionBlockScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current var qrImage by remember { mutableStateOf(ImageBitmap(1, 1, ImageBitmapConfig.Argb8888)) } val primaryColorHex = "#" + MaterialTheme.colorScheme.surface.toArgb().toHexString().substring(2) var finishNumberTarget by remember { mutableIntStateOf(0) } val finishNumber by animateIntAsState( targetValue = finishNumberTarget, animationSpec = keyframes { durationMillis = 12 * 1000 0 at 0 10 at 1 * 1000 60 at 3 * 1000 90 at 7 * 1000 91 at 10 * 1000 100 at 12 * 1000 }, label = "finish percent animation" ) LaunchedEffect(Unit) { println(primaryColorHex) val output = ByteArrayOutputStream() finishNumberTarget = 100 QRCode(context.getString(R.string.region_block_qr_content)) .render() .writeImage(output) val input = ByteArrayInputStream(output.toByteArray()) qrImage = BitmapFactory.decodeStream(input).asImageBitmap() } DisposableEffect(key1 = Unit) { onDispose { (context as Activity).finish() exitProcess(0) } } Surface( modifier = modifier .focusable() .onKeyEvent { (context as Activity).finish() exitProcess(0) }, shape = RoundedCornerShape(0.dp) ) { Box( modifier = Modifier .fillMaxSize() .padding(84.dp), contentAlignment = Alignment.CenterStart ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text( text = stringResource(R.string.region_block_character_painting), fontSize = 100.sp ) Column { Text( text = stringResource(R.string.region_block_title), style = MaterialTheme.typography.titleLarge ) Text( text = stringResource(R.string.region_block_subtitle_tv), style = MaterialTheme.typography.titleLarge ) } Text( text = "$finishNumber% 完成", style = MaterialTheme.typography.titleLarge ) Row( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Box( modifier = Modifier .size(80.dp) .background(Color.White), contentAlignment = Alignment.Center ) { Image( modifier = Modifier.size(64.dp), bitmap = qrImage, contentDescription = null ) } Column { Text(text = stringResource(R.string.region_block_solution_title)) Text(text = stringResource(R.string.region_block_solution_text)) } } } } } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun RegionBlockScreenPreview() { BVTheme { RegionBlockScreen() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/SeasonInfoScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens import android.app.Activity import android.content.res.Configuration import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke 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.Column import androidx.compose.foundation.layout.PaddingValues 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.heightIn 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.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.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ViewModule import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Scaffold 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.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.draw.drawWithContent import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode 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 androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Glow import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.video.season.Episode import dev.aaa1115910.biliapi.entity.video.season.PgcSeason import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.player.entity.VideoListPgcEpisode import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.CommentPanel import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.buttons.SeasonInfoButtons import dev.aaa1115910.bv.tv.util.launchPlayerActivity import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.focusedScale import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.onBackPressed import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.resizedImageUrl import dev.aaa1115910.bv.util.swapList import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.SeasonViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject import kotlin.math.ceil /** * 生成剧集标题 * @param episode 剧集信息 * @param sectionTitle 所属章节标题 * @return 格式化后的剧集标题 */ private fun generateEpisodeTitle( episode: Episode?, sectionTitle: String ): String { if(episode == null) return "" return if (episode.longTitle.isNotEmpty()) { runCatching { "第 ${episode.title.toInt()} 集 " }.getOrDefault("") + episode.longTitle } else if (sectionTitle == "正片") { //如果 title 是数字的话,就会返回 "第 x 集" //如果 title 不是数字的话(例如 SP),就会原样使用 title runCatching { "第 ${episode.title.toInt()} 集" }.getOrDefault(episode.title) } else { episode.title } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SeasonInfoScreen( modifier: Modifier = Modifier, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, videoInfoRepository: VideoInfoRepository = koinInject(), seasonViewModel: SeasonViewModel = koinViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val intent = (context as Activity).intent val logger = KotlinLogging.logger { } var paused by remember { mutableStateOf(false) } var showSeasonSelector by remember { mutableStateOf(false) } var showCommentPanel by remember { mutableStateOf(false) } val commentButtonFocusRequester = remember { FocusRequester() } val playButtonFocusRequester = remember { FocusRequester() } val onClickVideo: (avid: Long, cid: Long, epid: Int, episodeTitle: String, startTime: Int) -> Unit = { avid, cid, epid, episodeTitle, startTime -> logger.debug { "onClickVideo: [avid=$avid, cid=$cid, epid=$epid, episodeTitle=$episodeTitle, startTime=$startTime]" } if (cid != 0L) { videoInfoRepository.description = seasonViewModel.seasonData?.description ?: "" videoInfoRepository.tags = emptyList() launchPlayerActivity( context = context, avid = avid, cid = cid, title = seasonViewModel.seasonData!!.title, partTitle = episodeTitle, played = startTime * 1000, fromSeason = true, subType = seasonViewModel.seasonData?.subType, epid = epid, seasonId = seasonViewModel.seasonData?.seasonId, proxyArea = seasonViewModel.proxyArea, playerIconIdle = seasonViewModel.seasonData?.playerIcon?.idle ?: "", playerIconMoving = seasonViewModel.seasonData?.playerIcon?.moving ?: "" ) } else { //如果 cid==0,就需要跳转回 VideoInfoActivity 去获取 cid 再跳转播放器 VideoInfoActivity.actionStart( context = context, aid = avid, fromSeason = true ) } } val onClickFollow: (Boolean) -> Unit = { scope.launch(Dispatchers.IO) { if (seasonViewModel.isFollowing) seasonViewModel.unFollowSeason() else seasonViewModel.followSeason() } } val onClickCover = { if (seasonViewModel.seasonData?.seasons?.isNotEmpty() == true) showSeasonSelector = true } val onShowComment = { showCommentPanel = true } val getCommentAid = { val lastEpId = seasonViewModel.lastPlayProgress?.lastEpId if (lastEpId != null) { // 查找最后播放的剧集 seasonViewModel.seasonData?.episodes?.find { it.id == lastEpId }?.aid ?: seasonViewModel.seasonData?.sections?.mapNotNull { section -> section.episodes.find { it.id == lastEpId }?.aid }?.firstOrNull() } else { // 没有播放记录,使用第一集 seasonViewModel.seasonData?.episodes?.firstOrNull()?.aid } ?: 0L } LaunchedEffect(Unit) { videoInfoRepository.relatedVideos.clear() val epId = intent.getIntExtra("epid", 0) val seasonId = intent.getIntExtra("seasonid", 0) val proxyAreaIndex = intent.getIntExtra("proxy_area", 0) val proxyArea = ProxyArea.entries[proxyAreaIndex] logger.fInfo { "Read extras from content: [epId=$epId, seasonId=$seasonId, proxyArea=$proxyArea]" } seasonViewModel.epId = epId seasonViewModel.seasonId = seasonId seasonViewModel.proxyArea = proxyArea if (seasonViewModel.epId != null || seasonViewModel.seasonId != null) { scope.launch(Dispatchers.IO) { seasonViewModel.updateSeasonData() } } else { context.finish() } } LaunchedEffect(seasonViewModel.seasonData) { seasonViewModel.seasonData?.let { logger.fInfo { "season data change: ${seasonViewModel.seasonData}" } seasonViewModel.lastPlayProgress = it.userStatus.progress //请求默认焦点到播放按钮上 delay(300) playButtonFocusRequester.requestFocus(scope) } } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) { paused = true } else if (event == Lifecycle.Event.ON_RESUME) { // 如果 pause==true 那可能是从播放页返回回来的,此时更新历史记录 if (paused) { scope.launch(Dispatchers.IO) { seasonViewModel.updateLastPlayProgress() } } } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } if (seasonViewModel.seasonData == null) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface), contentAlignment = Alignment.Center ) { if (seasonViewModel.tip == "Loading") { LoadingTip() } else { Text( text = seasonViewModel.tip ) } } } else { val seasonData = seasonViewModel.seasonData!! Scaffold( modifier = modifier ) { innerPadding -> LazyColumn( modifier = Modifier .padding(innerPadding) .fillMaxSize(), contentPadding = PaddingValues(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { SeasonInfoPart( playButtonFocusRequester = playButtonFocusRequester, title = seasonData.title, cover = seasonData.cover, newEpDesc = seasonData.newEpDesc, description = seasonData.description, lastPlayedIndex = seasonViewModel.lastPlayProgress?.lastEpId ?: -1, lastPlayedTitle = generateEpisodeTitle(seasonData.episodes.find { it.id == seasonViewModel.lastPlayProgress?.lastEpId }, seasonData.title), following = seasonViewModel.isFollowing, isPublished = seasonData.publish.isPublished, publishDate = seasonData.publish.publishDate, seasonCount = seasonData.seasons.size, onPlay = { logger.fInfo { "Click play button" } var playAid = -1L var playCid = -1L val playEpid: Int var episodeList: List = emptyList() if (seasonViewModel.lastPlayProgress == null) { logger.fInfo { "Didn't find any play record" } //未登录或无播放记录,此时lastPlayProgress==null,默认播放第一集正片 playAid = seasonViewModel.seasonData?.episodes?.first()?.aid ?: -1 playCid = seasonViewModel.seasonData?.episodes?.first()?.cid ?: -1 playEpid = seasonViewModel.seasonData?.episodes?.first()?.id ?: -1 if (playCid == -1L) { R.string.season_no_feature_film.toast(context) } else { episodeList = seasonViewModel.seasonData?.episodes ?: emptyList() } } else { //已登录且有播放记录 logger.fInfo { "Find play record: ${seasonViewModel.lastPlayProgress}" } //懒得去改播放器那边来支持epid,就直接在这边查找cid了 playEpid = seasonViewModel.lastPlayProgress!!.lastEpId seasonViewModel.seasonData?.episodes?.forEach { if (it.id == playEpid) { playAid = it.aid playCid = it.cid episodeList = seasonViewModel.seasonData?.episodes ?: emptyList() } } if (playCid == -1L) { seasonViewModel.seasonData?.sections?.forEach { section -> section.episodes.forEach { if (it.id == playEpid) { playAid = it.aid playCid = it.cid episodeList = section.episodes } } } } if (playCid == -1L) { logger.fInfo { "Can't find cid" } "无法判断最后播放的剧集".toast(context) } } logger.fInfo { "Play aid: $playAid, cid: $playCid" } val lastEpId = seasonViewModel.lastPlayProgress?.lastEpId val ep = seasonViewModel.seasonData?.episodes?.find { it.id == lastEpId } ?: seasonViewModel.seasonData?.episodes?.find { it.cid == playCid } if (playCid != -1L) { onClickVideo( playAid, playCid, playEpid, generateEpisodeTitle(ep, seasonViewModel.seasonData!!.title), seasonViewModel.lastPlayProgress?.lastTime ?: 0 ) val partVideoList = episodeList.mapIndexed { index, episode -> VideoListPgcEpisode( aid = episode.aid, cid = episode.cid, epid = episode.id, seasonId = seasonViewModel.seasonData?.seasonId, title = seasonViewModel.seasonData!!.title, partTitle = runCatching { "第 ${episode.title.toInt()} 集" }.getOrDefault(episode.title) + " " + episode.longTitle, index = index, cover = episode.cover, duration = episode.duration / 1000, pubDate = episode.pubDate ) } videoInfoRepository.videoList.clear() videoInfoRepository.videoList.addAll(partVideoList) } }, onClickFollow = onClickFollow, onClickCover = onClickCover, onShowComment = onShowComment, commentButtonFocusRequester = commentButtonFocusRequester ) } if (seasonViewModel.seasonData?.episodes?.isNotEmpty() == true) { item { SeasonEpisodeRow( title = stringResource(R.string.season_feature_film), episodes = seasonViewModel.seasonData?.episodes ?: emptyList(), lastPlayedId = seasonViewModel.lastPlayProgress?.lastEpId ?: 0, lastPlayedTime = seasonViewModel.lastPlayProgress?.lastTime ?: 0, onClick = { avid, cid, epid, episodeTitle, startTime -> onClickVideo(avid, cid, epid, episodeTitle, startTime) val partVideoList = seasonViewModel.seasonData?.episodes?.mapIndexed { index, episode -> VideoListPgcEpisode( aid = episode.aid, cid = episode.cid, epid = episode.id, seasonId = seasonViewModel.seasonData?.seasonId, title = seasonViewModel.seasonData!!.title, partTitle = runCatching { "第 ${episode.title.toInt()} 集" }.getOrDefault(episode.title) + " " + episode.longTitle, index = index, cover = episode.cover, duration = episode.duration, pubDate = episode.pubDate ) } ?: emptyList() videoInfoRepository.videoList.clear() videoInfoRepository.videoList.addAll(partVideoList) } ) } } seasonViewModel.seasonData?.sections?.forEach { section -> item { SeasonEpisodeRow( title = section.title, episodes = section.episodes, lastPlayedId = seasonViewModel.lastPlayProgress?.lastEpId ?: 0, lastPlayedTime = seasonViewModel.lastPlayProgress?.lastTime ?: 0, onClick = { avid, cid, epid, episodeTitle, startTime -> onClickVideo(avid, cid, epid, episodeTitle, startTime) val partVideoList = section.episodes.mapIndexed { index, episode -> VideoListPgcEpisode( aid = episode.aid, cid = episode.cid, epid = episode.id, seasonId = seasonViewModel.seasonData?.seasonId, title = runCatching { "第 ${episode.title.toInt()} 集" }.getOrDefault(episode.title) + " " + episode.longTitle, index = index, cover = episode.cover, duration = episode.duration, pubDate = episode.pubDate ) } videoInfoRepository.videoList.clear() videoInfoRepository.videoList.addAll(partVideoList) } ) } } item { Spacer(modifier = Modifier.height(64.dp)) } } } } SeasonSelector( show = showSeasonSelector, onHideSelector = { showSeasonSelector = false runCatching { playButtonFocusRequester.requestFocus(scope) } }, currentSeasonId = seasonViewModel.seasonId ?: 0, seasons = seasonViewModel.seasonData?.seasons ?: emptyList(), onClickSeason = { sid -> if ((seasonViewModel.seasonId ?: 0) != sid) { seasonViewModel.seasonData = null seasonViewModel.seasonId = sid seasonViewModel.epId = null scope.launch(Dispatchers.IO) { seasonViewModel.updateSeasonData() } } } ) CommentPanel( show = showCommentPanel, oid = getCommentAid(), onHide = { showCommentPanel = false commentButtonFocusRequester.requestFocus(scope) }, episodes = seasonViewModel.seasonData?.episodes ?: emptyList(), sections = seasonViewModel.seasonData?.sections ?: emptyList(), initialEpisodeId = seasonViewModel.lastPlayProgress?.lastEpId ?: -1, onEpisodeChange = { episode -> logger.debug { "User viewed comments for episode: ${episode.id} (${episode.title})" } } ) } @Composable fun SeasonCover( modifier: Modifier = Modifier, cover: String, seasonCount: Int, onClick: () -> Unit ) { val isPreview = LocalInspectionMode.current var hasFocus by remember { mutableStateOf(false) } val coverBottomTipHeight by animateDpAsState( targetValue = if (hasFocus && seasonCount != 0) 28.dp else 0.dp, label = "Cover bottom tip height" ) Card( modifier = modifier.onFocusChanged { hasFocus = it.hasFocus }, onClick = onClick, shape = CardDefaults.shape(shape = MaterialTheme.shapes.medium), glow = CardDefaults.glow( focusedGlow = Glow( elevationColor = MaterialTheme.colorScheme.inverseSurface, elevation = 16.dp ) ), border = if (Build.VERSION.SDK_INT < 31) { CardDefaults.border() } else { CardDefaults.border( focusedBorder = Border(BorderStroke(0.dp, Color.Transparent)) ) } ) { Box { AsyncImage( modifier = Modifier .height(260.dp) .aspectRatio(0.75f) .background(if (isPreview) Color.White else Color.Transparent), model = cover, contentDescription = null, contentScale = ContentScale.FillHeight ) Row( modifier = Modifier .align(Alignment.BottomCenter) .width(195.dp) .height(coverBottomTipHeight) .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text( modifier = Modifier, text = stringResource(R.string.season_count_tip, seasonCount), ) } } } } @Composable fun SeasonBaseInfo( modifier: Modifier = Modifier, title: String, newEpDesc: String, description: String, lastPlayedIndex: Int, lastPlayedTitle: String = "", following: Boolean, isPublished: Boolean, publishDate: String, onPlay: () -> Unit, onClickFollow: (follow: Boolean) -> Unit, onShowComment: () -> Unit = {}, commentButtonFocusRequester: FocusRequester = remember { FocusRequester() }, playButtonFocusRequester: FocusRequester ) { Column( modifier = modifier .heightIn(min = 260.dp), verticalArrangement = Arrangement.SpaceBetween ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = title, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onSurface ) Text(text = newEpDesc) Text(text = description) } Spacer(modifier = Modifier.height(12.dp)) SeasonInfoButtons( lastPlayedIndex = lastPlayedIndex, lastPlayedTitle = lastPlayedTitle, following = following, isPublished = isPublished, publishDate = publishDate, onPlay = onPlay, onClickFollow = onClickFollow, onShowComment= onShowComment, commentButtonFocusRequester = commentButtonFocusRequester, playButtonFocusRequester = playButtonFocusRequester, ) } } @Composable fun SeasonInfoPart( modifier: Modifier = Modifier, title: String, cover: String, newEpDesc: String, description: String, lastPlayedIndex: Int, lastPlayedTitle: String = "", following: Boolean, isPublished: Boolean, publishDate: String, seasonCount: Int, onPlay: () -> Unit, onClickFollow: (follow: Boolean) -> Unit, onClickCover: () -> Unit, onShowComment: () -> Unit = {}, commentButtonFocusRequester: FocusRequester = remember { FocusRequester() }, playButtonFocusRequester: FocusRequester ) { Row( modifier = modifier .padding(horizontal = 32.dp, vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically ) { SeasonCover( cover = cover, seasonCount = seasonCount, onClick = onClickCover ) SeasonBaseInfo( title = title, newEpDesc = newEpDesc, description = description, lastPlayedIndex = lastPlayedIndex, lastPlayedTitle = lastPlayedTitle, following = following, isPublished = isPublished, publishDate = publishDate, onPlay = onPlay, onClickFollow = onClickFollow, onShowComment = onShowComment, commentButtonFocusRequester = commentButtonFocusRequester, playButtonFocusRequester = playButtonFocusRequester ) } } @Composable fun SeasonEpisodeButton( modifier: Modifier = Modifier, partTitle: String = "", title: String, cover: String, duration: Int, played: Int = 0, isLastPlayed: Boolean = false, onClick: () -> Unit ) { val isPreview = LocalInspectionMode.current Surface( modifier = modifier, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ), scale = ClickableSurfaceDefaults.scale(scale = 1f, focusedScale = 1f), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), border = ClickableSurfaceDefaults.border( border = if (isLastPlayed) { Border( border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)), shape = MaterialTheme.shapes.medium ) } else Border.None, focusedBorder = Border( border = BorderStroke(2.dp, MaterialTheme.colorScheme.border), shape = MaterialTheme.shapes.medium ), pressedBorder = Border( border = BorderStroke(2.dp, MaterialTheme.colorScheme.border), shape = MaterialTheme.shapes.medium ) ), onClick = onClick ) { Row { val coverBackground by remember { mutableStateOf(if (played != 0) Color.Black.copy(alpha = 0.2f) else Color.Transparent) } Box( modifier = Modifier.background(coverBackground) ) { AsyncImage( modifier = Modifier .height(80.dp) .aspectRatio(1.6f) .clip(MaterialTheme.shapes.medium) .background(if (isPreview) Color.White else Color.Transparent), model = cover.resizedImageUrl(ImageSize.Cover), contentDescription = null, contentScale = ContentScale.FillBounds ) } Box( modifier = Modifier .size(140.dp, 80.dp) ) { Box( modifier = Modifier .background(Color.Black.copy(alpha = 0.2f)) .fillMaxHeight() .fillMaxWidth(if (duration == 0) 0f else if (played < 0) 1f else ((played * 1000f) / duration)) ) {} Column( modifier = Modifier .fillMaxHeight() .padding(horizontal = 8.dp), verticalArrangement = Arrangement.SpaceAround ) { Box( modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart ) { Text(text = partTitle) } Box( modifier = Modifier.weight(2f), contentAlignment = Alignment.TopStart ) { Text( text = title, maxLines = 2, overflow = TextOverflow.Ellipsis ) } } } } } } @Composable fun SeasonEpisodesDialog( modifier: Modifier = Modifier, show: Boolean, title: String, episodes: List, lastPlayedId: Int = 0, lastPlayedTime: Int = 0, onHideDialog: () -> Unit, onClick: (avid: Long, cid: Long, epid: Int, episodeTitle: String, startTime: Int) -> Unit ) { val scope = rememberCoroutineScope() var selectedTabIndex by remember { mutableIntStateOf(0) } val tabCount by remember { mutableIntStateOf(ceil(episodes.size / 20.0).toInt()) } val selectedEpisodes = remember { mutableStateListOf() } val tabFocusRequester = remember { FocusRequester() } val tabRowFocusRequester = remember { FocusRequester() } val videoListFocusRequester = remember { FocusRequester() } val listState = rememberLazyGridState() LaunchedEffect(selectedTabIndex) { val fromIndex = selectedTabIndex * 20 var toIndex = (selectedTabIndex + 1) * 20 if (toIndex >= episodes.size) { toIndex = episodes.size } selectedEpisodes.swapList(episodes.subList(fromIndex, toIndex)) } LaunchedEffect(show) { if (show && tabCount > 1) tabFocusRequester.requestFocus(scope) if (show && tabCount == 1) videoListFocusRequester.requestFocus(scope) } if (show) { TvAlertDialog( modifier = modifier, title = { Text(text = title) }, onDismissRequest = { onHideDialog() }, confirmButton = {}, properties = DialogProperties(usePlatformDefaultWidth = false), text = { Column( modifier = Modifier .size(600.dp, 330.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { // TabRow 只有一项 Tab 时会导致崩溃,但如果只有一项 Tab 的时候也没必要显示 // https://issuetracker.google.com/issues/264018028 if (tabCount > 1) { TabRow( modifier = Modifier .onFocusChanged { if (it.hasFocus) { scope.launch(Dispatchers.Main) { listState.scrollToItem(0) } } } .focusRestorer() .focusRequester(tabRowFocusRequester), selectedTabIndex = selectedTabIndex, separator = { Spacer(modifier = Modifier.width(12.dp)) }, ) { for (i in 0 until tabCount) { Tab( modifier = if (i == 0) Modifier.focusRequester( tabFocusRequester ) else Modifier, selected = i == selectedTabIndex, onFocus = { selectedTabIndex = i }, ) { Text( text = "P${i * 20 + 1}-${(i + 1) * 20}", fontSize = 12.sp, color = LocalContentColor.current, modifier = Modifier.padding( horizontal = 16.dp, vertical = 6.dp ) ) } } } } LazyVerticalGrid( modifier = Modifier .onBackPressed { if (tabCount > 1) tabRowFocusRequester.requestFocus() else onHideDialog() }, state = listState, columns = GridCells.Fixed(2), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { itemsIndexed( items = selectedEpisodes, key = { index, episode -> "$index-episode-${episode.aid}-${episode.cid}" } ) { index, episode -> val episodeTitle by remember { mutableStateOf(generateEpisodeTitle(episode, title)) } val buttonModifier = if (index == 0) Modifier.focusRequester(videoListFocusRequester) else Modifier SeasonEpisodeButton( modifier = buttonModifier .focusedScale(0.95f), partTitle = if (title == "正片") { //如果 title 是数字的话,就会返回 "第 x 集" //如果 title 不是数字的话(例如 SP),就会原样使用 title runCatching { "第 ${episode.title.toInt()} 集" }.getOrDefault(episode.title) } else { "P${index + 1 + selectedTabIndex * 20}" }, title = episodeTitle, cover = episode.cover, played = if (episode.id == lastPlayedId) lastPlayedTime else 0, isLastPlayed = episode.id == lastPlayedId, duration = episode.duration, onClick = { onClick( episode.aid, episode.cid, episode.id, generateEpisodeTitle(episode, title), if (episode.id == lastPlayedId) lastPlayedTime else 0 ) } ) } } } } ) } } @Composable private fun SeasonEpisodeRowButton( modifier: Modifier = Modifier, hasFocus: Boolean = true, onClick: () -> Unit ) { val scale by animateFloatAsState( targetValue = if (hasFocus) 1f else 0.4f, label = "button scale", animationSpec = tween( durationMillis = 120 ) ) Surface( modifier = modifier, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.small), border = ClickableSurfaceDefaults.border( focusedBorder = Border( border = BorderStroke(2.dp, MaterialTheme.colorScheme.border), shape = MaterialTheme.shapes.small ) ), onClick = onClick ) { Box( modifier = Modifier .size(width = (40 * scale).dp, height = (42 * scale).dp), contentAlignment = Alignment.Center ) { Icon( modifier = Modifier .size(32.dp) .rotate(90f), imageVector = Icons.Rounded.ViewModule, contentDescription = null ) } } } @Composable fun SeasonEpisodeRow( modifier: Modifier = Modifier, title: String, episodes: List, lastPlayedId: Int = 0, lastPlayedTime: Int = 0, onClick: (avid: Long, cid: Long, epid: Int, episodeTitle: String, startTime: Int) -> Unit ) { val focusRequester = remember { FocusRequester() } val rowState = rememberLazyListState() var hasFocus by remember { mutableStateOf(false) } val titleColor = if (hasFocus) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) val titleFontSize by animateFloatAsState( targetValue = if (hasFocus) 30f else 14f, label = "title font size" ) var showEpisodesDialog by remember { mutableStateOf(false) } // 当存在历史记录时,滚动到对应集 LaunchedEffect(lastPlayedId, episodes) { if (lastPlayedId != 0 && episodes.isNotEmpty()) { val lastPlayedIndex = episodes.indexOfFirst { it.id == lastPlayedId } if (lastPlayedIndex != -1) { rowState.scrollToItem(lastPlayedIndex) } } } Column( modifier = modifier .onFocusChanged { hasFocus = it.hasFocus }, verticalArrangement = Arrangement.SpaceBetween ) { Row( modifier = Modifier.padding(start = 50.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = title, fontSize = titleFontSize.sp, color = titleColor ) SeasonEpisodeRowButton( hasFocus = hasFocus, onClick = { showEpisodesDialog = true } ) } LazyRow( modifier = Modifier .padding(top = 15.dp) .focusRestorer(focusRequester), state = rowState, contentPadding = PaddingValues(horizontal = 32.dp), horizontalArrangement = Arrangement.spacedBy(24.dp), ) { itemsIndexed( items = episodes, key = { index, episode -> "$index-episode-${episode.id}" } ) { index, episode -> val episodeTitle by remember { mutableStateOf(if (episode.longTitle != "") episode.longTitle else episode.title) } SeasonEpisodeButton( modifier = Modifier .ifElse(index == 0, Modifier.focusRequester(focusRequester)), partTitle = if (title == "正片") { //如果 title 是数字的话,就会返回 "第 x 集" //如果 title 不是数字的话(例如 SP),就会原样使用 title runCatching { "第 ${episode.title.toInt()} 集" }.getOrDefault(episode.title) } else { "P${index + 1}" }, title = episodeTitle, cover = episode.cover, played = if (episode.id == lastPlayedId) lastPlayedTime else 0, isLastPlayed = episode.id == lastPlayedId, duration = episode.duration, onClick = { val pTitle = generateEpisodeTitle(episode, title) onClick( episode.aid, episode.cid, episode.id, pTitle, if (episode.id == lastPlayedId) lastPlayedTime else 0 ) } ) } } } SeasonEpisodesDialog( show = showEpisodesDialog, title = title, episodes = episodes, lastPlayedId = lastPlayedId, lastPlayedTime = lastPlayedTime, onHideDialog = { showEpisodesDialog = false }, onClick = onClick ) } @Composable fun SeasonSelector( modifier: Modifier = Modifier, show: Boolean, onHideSelector: () -> Unit, currentSeasonId: Int, seasons: List, onClickSeason: (Int) -> Unit ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) { focusRequester.requestFocus() } } if (show) { SeasonSelectorContent( modifier = modifier .focusRequester(focusRequester), seasons = seasons, currentSeasonId = currentSeasonId, onClickSeason = { seasonId -> onClickSeason(seasonId) onHideSelector() } ) } BackHandler(show) { onHideSelector() } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun SeasonSelectorContent( modifier: Modifier = Modifier, currentSeasonId: Int, seasons: List, onClickSeason: (Int) -> Unit ) { val scope = rememberCoroutineScope() val rowState = rememberLazyListState() val logger = KotlinLogging.logger {} val currentSeasonFocusRequester = remember { FocusRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() } var scrolling by remember { mutableStateOf(false) } var currentSeasonIndex by remember { mutableIntStateOf(0) } val isCurrentSeasonInScreen by remember { derivedStateOf { rowState.layoutInfo.visibleItemsInfo.first().index <= currentSeasonIndex && rowState.layoutInfo.visibleItemsInfo.last().index >= currentSeasonIndex } } val scrollToCurrentSeason = { currentSeasonIndex = seasons.indexOfFirst { it.seasonId == currentSeasonId } logger.info { "Season row scroll to index $currentSeasonIndex" } if (currentSeasonIndex != -1) { if (isCurrentSeasonInScreen) { currentSeasonFocusRequester.requestFocus() } else { scope.launch { scrolling = true rowState.scrollToItem(currentSeasonIndex) } } } } LaunchedEffect(rowState.firstVisibleItemScrollOffset) { if (scrolling && isCurrentSeasonInScreen) { scrolling = false delay(300) currentSeasonFocusRequester.requestFocus() } } Surface( modifier = modifier .fillMaxSize() .onFocusChanged { if (it.hasFocus) scrollToCurrentSeason() }, shape = RoundedCornerShape(0.dp) ) { Box( modifier = Modifier.fillMaxSize() ) { Box( modifier = Modifier.fillMaxSize() ) { AsyncImage( modifier = Modifier .align(Alignment.TopEnd) .fillMaxHeight(0.7f) .graphicsLayer { alpha = 0.99f } .drawWithContent { val colors = listOf( Color.Black, Color.Transparent ) drawContent() drawRect( brush = Brush.horizontalGradient(colors), blendMode = BlendMode.DstOut ) drawRect( brush = Brush.verticalGradient(colors), blendMode = BlendMode.DstIn ) }, model = seasons[currentSeasonIndex].horizontalCover ?: "", contentDescription = null, contentScale = ContentScale.FillHeight, alpha = 1f ) Column( modifier = Modifier .align(Alignment.BottomStart) .padding( start = 48.dp, end = 48.dp, bottom = 300.dp ) ) { Text( text = seasons[currentSeasonIndex].title ?: seasons[currentSeasonIndex].shortTitle, style = MaterialTheme.typography.displayMedium ) } } Box( modifier = Modifier.align(Alignment.BottomStart) ) { LazyRow( modifier = Modifier.padding(bottom = 48.dp), state = rowState, contentPadding = PaddingValues(horizontal = 48.dp), horizontalArrangement = Arrangement.spacedBy(24.dp) ) { itemsIndexed( items = seasons, key = { index, season -> "$index-season-${season.seasonId}" } ) { index, season -> Card( modifier = Modifier .onFocusChanged { if (it.hasFocus) currentSeasonIndex = index } .ifElse( season.seasonId == currentSeasonId, Modifier.focusRequester(currentSeasonFocusRequester) ) .ifElse( season.seasonId == currentSeasonId, Modifier.bringIntoViewRequester(bringIntoViewRequester) ), glow = CardDefaults.glow( focusedGlow = Glow( elevationColor = MaterialTheme.colorScheme.inverseSurface, elevation = 16.dp ) ), border = if (Build.VERSION.SDK_INT < 31) { CardDefaults.border() } else { CardDefaults.border( focusedBorder = Border(BorderStroke(0.dp, Color.Transparent)) ) }, onClick = { onClickSeason(season.seasonId) } ) { AsyncImage( modifier = Modifier .width(160.dp) .aspectRatio(0.75f), model = seasons[index].cover, contentDescription = null ) } } } } } } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SeasonInfoPartPreview() { BVTheme { SeasonInfoPart( modifier = Modifier.fillMaxWidth(), title = "人生一串", cover = "http://i0.hdslb.com/bfs/bangumi/7a790c64ff70f12c11888be0532b6981a923afd5.jpg", newEpDesc = "已完结, 全8集", description = "由bilibili和旗帜传媒联合出品的《人生一串》是国内首档汇聚民间烧烤美食,呈现国人烧烤情结的深夜美食纪录片,本片将镜头伸向街头巷尾,讲述平民美食和市井传奇,以最独特的视角真实展现烧烤美食背后的独特情感。作为一档接地气的美食节目,《串》旨在展现每一串烧烤的魅力往事,和最真实的美味体验。", lastPlayedIndex = 3, lastPlayedTitle = "拯救灵依计划", following = false, isPublished = true, publishDate = "2021-04-30", seasonCount = 0, onPlay = {}, onClickFollow = {}, onClickCover = {}, playButtonFocusRequester = remember { FocusRequester() } ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun SeasonEpisodeRowPreview() { val episodes = remember { mutableStateListOf() } for (i in 0..10) { episodes.add( Episode( id = 0, aid = 0, bvid = "", cid = 0, epid = 1000 + i, title = "这可能是我这辈子距离梅西最近的一次", longTitle = "", cover = "", duration = 0, dimension = null, pages = emptyList() ) ) } BVTheme { SeasonEpisodeRow( title = "正片", episodes = episodes, onClick = { _, _, _, _, _ -> } ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SeasonSelectorPreview() { val seasons = listOf( PgcSeason( seasonId = 25210, title = "命运之夜 06版", shortTitle = "FATE TV", cover = "http://i0.hdslb.com/bfs/bangumi/1113d844ad3a9b42af576d80142146cbecc1b7ff.jpg", horizontalCover = "http://i0.hdslb.com/bfs/bangumi/e3993240914c3d881d97e4527a52efa2a9dcdeaf.jpg" ), PgcSeason( seasonId = 29006, title = "Fate/stay night UNLIMITED BLADE WORKS", shortTitle = "UBW 剧场版", cover = "http://i0.hdslb.com/bfs/bangumi/image/b7ee578ff3c258f173587db3f687fa2d56e3b8c1.jpg", horizontalCover = "http://i0.hdslb.com/bfs/archive/ae6fcc22f6c627a899bfe2d736765cc83cd4e827.png" ), PgcSeason( seasonId = 1586, title = "Fate/stay night [Unlimited Blade Works] 第一季", shortTitle = "UBW第一季", cover = "http://i0.hdslb.com/bfs/bangumi/image/e67e09c9e48a32371a81100e0f65a61b18aabb24.png", horizontalCover = "http://i0.hdslb.com/bfs/bangumi/25e9da6dd71e4aaa23a7dc04b6f97a94ea1ddd9d.jpg" ), PgcSeason( seasonId = 25210, title = "命运之夜 06版", shortTitle = "FATE TV", cover = "http://i0.hdslb.com/bfs/bangumi/1113d844ad3a9b42af576d80142146cbecc1b7ff.jpg", horizontalCover = "http://i0.hdslb.com/bfs/bangumi/e3993240914c3d881d97e4527a52efa2a9dcdeaf.jpg" ), PgcSeason( seasonId = 29006, title = "Fate/stay night UNLIMITED BLADE WORKS", shortTitle = "UBW 剧场版", cover = "http://i0.hdslb.com/bfs/bangumi/image/b7ee578ff3c258f173587db3f687fa2d56e3b8c1.jpg", horizontalCover = "http://i0.hdslb.com/bfs/archive/ae6fcc22f6c627a899bfe2d736765cc83cd4e827.png" ), PgcSeason( seasonId = 1586, title = "Fate/stay night [Unlimited Blade Works] 第一季", shortTitle = "UBW第一季", cover = "http://i0.hdslb.com/bfs/bangumi/image/e67e09c9e48a32371a81100e0f65a61b18aabb24.png", horizontalCover = "http://i0.hdslb.com/bfs/bangumi/25e9da6dd71e4aaa23a7dc04b6f97a94ea1ddd9d.jpg" ), ) BVTheme { SeasonSelectorContent( seasons = seasons, currentSeasonId = 25210, onClickSeason = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/TagScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens import android.app.Activity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth 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.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.tv.util.stableItemKey import dev.aaa1115910.bv.viewmodel.TagViewModel import dev.aaa1115910.bv.repository.VideoInfoRepository import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun TagScreen( modifier: Modifier = Modifier, tagViewModel: TagViewModel = koinViewModel() ) { val context = LocalContext.current val videoInfoRepository: VideoInfoRepository = koinInject() val listFocusRestorer = rememberTvLazyListFocusRestorer() var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < 4 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) LaunchedEffect(Unit) { val intent = (context as Activity).intent if (intent.hasExtra("tagId")) { val tagId = intent.getIntExtra("tagId", 0) val tagName = intent.getStringExtra("tagName") ?: "" tagViewModel.tagId = tagId tagViewModel.tagName = tagName tagViewModel.update() } else { context.finish() } } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = tagViewModel.tagName, fontSize = titleFontSize.sp ) Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = stringResource( R.string.load_data_count, tagViewModel.topVideos.size ), color = Color.White.copy(alpha = 0.6f) ) AnimatedVisibility(visible = tagViewModel.noMore) { Text( text = stringResource(R.string.load_data_no_more), color = Color.White.copy(alpha = 0.6f) ) } } } } } ) { innerPadding -> ProvideListBringIntoViewSpec(padding = 26.dp) { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier( Modifier .padding(innerPadding) .blockDownFocusExitAtGridEnd( currentIndex = currentIndex, itemCount = tagViewModel.topVideos.size, columnCount = 4 ) ), columns = GridCells.Fixed(4), contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp) ) { itemsIndexed( items = tagViewModel.topVideos, key = { index, video -> "$index-${video.stableItemKey()}" } ) { index, video -> Box( contentAlignment = Alignment.Center ) { SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index), data = video, onClick = { videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll(tagViewModel.topVideos) VideoInfoActivity.actionStart(context, video.avid) }, onLongClick = { UpInfoActivity.actionStart( context, mid = video.upId, name = video.upName, face = video.upFace ) }, onFocus = { currentIndex = index if (index + 20 > tagViewModel.topVideos.size) { tagViewModel.update() } } ) } } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/VideoInfoScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens import android.app.Activity import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.animateScrollBy 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.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.heightIn 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.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.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Done import androidx.compose.material.icons.rounded.ViewModule import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.SuggestionChip import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.request.ImageRequest import coil.transform.BlurTransformation import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.biliapi.entity.FavoriteFolderMetadata import dev.aaa1115910.biliapi.entity.video.Dimension import dev.aaa1115910.biliapi.entity.video.Tag import dev.aaa1115910.biliapi.entity.video.VideoDetail import dev.aaa1115910.biliapi.entity.video.VideoPage import dev.aaa1115910.biliapi.entity.video.season.Episode import dev.aaa1115910.biliapi.http.BiliPlusHttpApi import dev.aaa1115910.biliapi.repositories.UserRepository import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.player.entity.VideoListItem import dev.aaa1115910.bv.player.entity.VideoListPart import dev.aaa1115910.bv.player.entity.VideoListUgcEpisode import dev.aaa1115910.bv.player.entity.VideoListUgcEpisodeTitle import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.activities.video.TagActivity import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.CommentPanel import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.UpIcon import dev.aaa1115910.bv.tv.component.buttons.LikeButton import dev.aaa1115910.bv.tv.component.buttons.CoinButton import dev.aaa1115910.bv.tv.component.buttons.FavoriteButton import dev.aaa1115910.bv.tv.manager.VideoUserActionManager import dev.aaa1115910.bv.tv.manager.VideoUserActionManager.getStateFlow import dev.aaa1115910.bv.tv.component.videocard.VideosRow import dev.aaa1115910.bv.tv.util.launchPlayerActivity import dev.aaa1115910.bv.tv.manager.FollowStateManager import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.fWarn import dev.aaa1115910.bv.util.focusedBorder import dev.aaa1115910.bv.util.formatHourMinSec import dev.aaa1115910.bv.util.formatPubTimeString import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.resizedImageUrl import dev.aaa1115910.bv.util.onBackPressed import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.swapList import dev.aaa1115910.bv.util.swapListWithMainContext import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.video.VideoDetailViewModel import dev.aaa1115910.bv.viewmodel.video.VideoInfoState import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel import org.koin.compose.getKoin import java.util.Date import kotlin.math.ceil @Composable fun VideoInfoScreen( modifier: Modifier = Modifier, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, videoInfoRepository: VideoInfoRepository = getKoin().get(), videoDetailViewModel: VideoDetailViewModel = koinViewModel(), userRepository: UserRepository = getKoin().get(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() val screenScope = lifecycleOwner.lifecycleScope val intent = (context as Activity).intent val logger = KotlinLogging.logger { } val defaultFocusRequester = remember { FocusRequester() } val lazyListState = rememberLazyListState() var showFollowButton by remember { mutableStateOf(false) } var isFollowing by remember { mutableStateOf(false) } // 监听关注状态变化 val followStateMap by FollowStateManager.followStateMap.collectAsState() // 当关注状态map变化时,更新当前用户的关注状态 LaunchedEffect(followStateMap, videoDetailViewModel.videoDetail?.author?.mid) { videoDetailViewModel.videoDetail?.author?.mid?.let { mid -> FollowStateManager.getFollowState(mid)?.let { following -> isFollowing = following } } } // 添加用于管理简介对话框的状态 var showDescriptionDialog by remember { mutableStateOf(false) } // 添加用于管理评论浮层的状态 var showCommentPanel by remember { mutableStateOf(false) } val commentButtonFocusRequester = remember { FocusRequester() } var lastPlayedCid by remember { mutableLongStateOf(0) } var lastPlayedTime by remember { mutableIntStateOf(0) } var tip by remember { mutableStateOf("Loading") } var showUGCVideoInfo by remember { mutableStateOf(Prefs.showUGCVideoInfo) } var fromSeason by remember { mutableStateOf(false) } var fromPlayer by remember { mutableStateOf(false) } var paused by remember { mutableStateOf(false) } var proxyArea by remember { mutableStateOf(ProxyArea.MainLand) } var intentAid by remember { mutableLongStateOf(0L) } val containsVerticalScreenVideo by remember { derivedStateOf { videoDetailViewModel.videoDetail?.pages?.any { it.dimension.isVertical } ?: false } } var favorited by remember { mutableStateOf(false) } // local copies for like / coin sync var liked by remember { mutableStateOf(false) } var isCoin by remember { mutableStateOf(false) } val videoInFavoriteFolderIds = remember { mutableStateListOf() } // subscribe shared action state by aid val aid = videoDetailViewModel.videoDetail?.aid ?: 0L val sharedActionStateFlow = remember(aid) { getStateFlow(aid, Prefs.uid) } val sharedState by sharedActionStateFlow.collectAsState() // keep local copies in sync with shared state LaunchedEffect(sharedState) { // sync favorite/like/coin from shared manager favorited = sharedState.favorited liked = sharedState.liked isCoin = sharedState.coin videoInFavoriteFolderIds.swapList(sharedState.favoriteFolderIds) } val setHistory = { logger.info { "play history: ${videoDetailViewModel.videoDetail?.history}" } lastPlayedCid = videoDetailViewModel.videoDetail?.history?.lastPlayedCid ?: 0 lastPlayedTime = videoDetailViewModel.videoDetail?.history?.progress ?: 0 } val updateHistory = { screenScope.launch(Dispatchers.IO) { runCatching { videoDetailViewModel.loadDetailOnlyUpdateHistory(videoDetailViewModel.videoDetail!!.aid) } withContext(Dispatchers.Main) { setHistory() } } } val updateFollowingState: () -> Unit = { screenScope.launch(Dispatchers.IO) { val userMid = videoDetailViewModel.videoDetail?.author?.mid ?: -1 logger.fInfo { "Checking is following user $userMid" } val result = FollowStateManager.ensureFollowState(userMid) logger.fInfo { "Following user result: $result" } withContext(Dispatchers.Main) { showFollowButton = result != null if (result != null) { isFollowing = result } } } } val addFollow: (afterModify: (success: Boolean) -> Unit) -> Unit = { afterModify -> screenScope.launch(Dispatchers.IO) { val userMid = videoDetailViewModel.videoDetail?.author?.mid ?: -1 logger.fInfo { "Add follow to user $userMid" } val success = userRepository.followUser( mid = userMid, preferApiType = Prefs.apiType ) logger.fInfo { "Add follow result: $success" } // 更新缓存状态 if (success) { FollowStateManager.updateFollowState(userMid, true) } afterModify(success) } } val delFollow: (afterModify: (success: Boolean) -> Unit) -> Unit = { afterModify -> screenScope.launch(Dispatchers.IO) { val userMid = videoDetailViewModel.videoDetail?.author?.mid ?: -1 logger.fInfo { "Del follow to user $userMid" } val success = userRepository.unfollowUser( mid = userMid, preferApiType = Prefs.apiType ) logger.fInfo { "Del follow result: $success" } // 更新缓存状态 if (success) { FollowStateManager.updateFollowState(userMid, false) } afterModify(success) } } val updateVideoFavoriteData: (List) -> Unit = { folderIds -> screenScope.launch { val success = VideoUserActionManager.updateVideoFavoriteFolders(aid, folderIds, Prefs.uid) if (!success) { "收藏操作失败!此收藏夹收藏数量已达上限(1000)".toast(context) } } } val addVideoToDefaultFavoriteFolder: () -> Unit = { screenScope.launch { val success = VideoUserActionManager.addToDefaultFavoriteFolder(aid, Prefs.uid) if (!success) { "添加收藏失败!默认收藏夹不存在?".toast(context) } } } val updateVideoUserActionData: suspend () -> Unit = { // update shared state from loaded data val configAid = videoDetailViewModel.videoDetail?.aid ?: 0L VideoUserActionManager.updateFromLoadedData( configAid, liked = videoDetailViewModel.videoDetail?.userActions?.like ?: false, favorited = videoDetailViewModel.videoDetail?.userActions?.favorite ?: false, coin = videoDetailViewModel.videoDetail?.userActions?.coin ?: false ) } val updateUgcSeasonSectionVideoList: (Int) -> Unit = { sectionIndex -> val partVideoList = mutableListOf() val sectionTitle = videoDetailViewModel.videoDetail!!.ugcSeason!!.sections[sectionIndex]?.title ?: "" videoDetailViewModel.videoDetail!!.ugcSeason!!.sections[sectionIndex].episodes.mapIndexed { epIndex, episode -> if (episode.pages.size == 1) { episode.pages.mapIndexed { pageInd, videoPage -> partVideoList.add( VideoListUgcEpisode( aid = episode.aid, cid = videoPage.cid, title = if (sectionTitle == "正片") episode.title else sectionTitle, partTitle = if (sectionTitle == "正片") "" else episode.title, index = epIndex, cover = episode.cover, duration = episode.duration, pubDate = episode.pubDate, ) ) } } else { partVideoList.add( VideoListUgcEpisodeTitle( title = episode.title, index = epIndex, ) ) episode.pages.mapIndexed { pageIndex, videoPage -> partVideoList.add( VideoListPart( aid = episode.aid, cid = videoPage.cid, title = episode.title, partTitle = videoPage.title, index = pageIndex, cover = episode.cover, duration = videoPage.duration, pubDate = episode.pubDate, ) ) } } } videoInfoRepository.videoList.clear() videoInfoRepository.videoList.addAll(partVideoList) } suspend fun addVideoLike(): Boolean { val configAid = videoDetailViewModel.videoDetail?.aid ?: 0L return VideoUserActionManager.addLike(configAid, Prefs.uid) } suspend fun delVideoLike(): Boolean { val configAid = videoDetailViewModel.videoDetail?.aid ?: 0L return VideoUserActionManager.delLike(configAid, Prefs.uid) } suspend fun addVideoCoin(): Boolean { val configAid = videoDetailViewModel.videoDetail?.aid ?: 0L return VideoUserActionManager.addCoin(configAid, Prefs.uid) } LaunchedEffect(Unit) { if (intent.hasExtra("aid")) { val aid = intent.getLongExtra("aid", 170001) intentAid = aid var cid = intent.getLongExtra("cid", 0) fromSeason = intent.getBooleanExtra("fromSeason", false) fromPlayer = intent.getBooleanExtra("fromPlayer", false) proxyArea = ProxyArea.entries[intent.getIntExtra("proxy_area", 0)] //获取视频信息 screenScope.launch(Dispatchers.IO) { if (proxyArea != ProxyArea.MainLand) { runCatching { val seasonId = BiliPlusHttpApi.getSeasonIdByAvid(aid) logger.info { "Get season id from biliplus: $seasonId" } seasonId?.let { logger.fInfo { "Redirect to season $seasonId" } SeasonInfoActivity.actionStart( context = context, seasonId = seasonId, proxyArea = proxyArea ) context.finish() } }.onFailure { logger.fWarn { "Redirect failed: ${it.stackTraceToString()}" } } } runCatching { videoDetailViewModel.loadDetail(aid, fromSeason) updateVideoUserActionData() withContext(Dispatchers.Main) { setHistory() } videoInfoRepository.relatedVideos.clear() videoInfoRepository.description = videoDetailViewModel.videoDetail?.description ?: "" videoInfoRepository.tags = videoDetailViewModel.videoDetail?.tags ?: emptyList() if (!fromSeason) { if (Prefs.isLogin) updateFollowingState() videoInfoRepository.relatedVideos.addAll( videoDetailViewModel.relatedVideos.subList( 0, videoDetailViewModel.relatedVideos.size)) } // 从播放器推荐视频打开时 fromPlayer=true 并显示loading。300m后 fromPlayer改成false,此后从播放器返回详情页,正常显示详情内容 //如果是从剧集跳转过来的或设置不显示视频详情,就直接播放 P1 if (fromSeason || !showUGCVideoInfo || fromPlayer) { val shouldFinishAfterAutoLaunch = fromPlayer && !Prefs.videoInfoHistoryIncludeFromPlayer val playPart = videoDetailViewModel.videoDetail!!.pages.first() cid = cid.takeIf { it > 0L } ?: playPart.cid if (videoDetailViewModel.videoDetail!!.ugcSeason !== null) { val sectionIndex = videoDetailViewModel.videoDetail!!.ugcSeason!!.sections .indexOfFirst { section -> section.episodes.any { it.cid == cid || it.pages.any { it.cid == cid } } } updateUgcSeasonSectionVideoList(sectionIndex) } // 检查Activity是否已经finish,如果已关闭则不启动播放器 if (!context.isFinishing && !context.isDestroyed) { launchPlayerActivity( context = context, avid = videoDetailViewModel.videoDetail!!.aid, cid = cid, title = videoDetailViewModel.videoDetail!!.title, partTitle = videoDetailViewModel.videoDetail!!.pages.find { it.cid == cid }!!.title, played = if (cid == lastPlayedCid) lastPlayedTime * 1000 else 0, fromSeason = fromSeason, isVerticalVideo = videoDetailViewModel.videoDetail!!.pages.find { it.cid == cid }!!.dimension.isVertical, playerIconIdle = videoDetailViewModel.videoDetail!!.playerIcon?.idle ?: "", playerIconMoving = videoDetailViewModel.videoDetail!!.playerIcon?.moving ?: "", play = videoDetailViewModel.videoDetail!!.stat.view, danmaku = videoDetailViewModel.videoDetail!!.stat.danmaku, like = videoDetailViewModel.videoDetail!!.stat.like, coin = videoDetailViewModel.videoDetail!!.stat.coin, favorite = videoDetailViewModel.videoDetail!!.stat.favorite, upName = videoDetailViewModel.videoDetail!!.author.name, upId = videoDetailViewModel.videoDetail!!.author.mid, upFace = videoDetailViewModel.videoDetail!!.author.face, pubTime = videoDetailViewModel.videoDetail!!.publishDate.formatPubTimeString() ) } if (shouldFinishAfterAutoLaunch) { context.finish() } else if (fromPlayer) { // 清除标记, 以便从播放器返回过来的可以进入详情页 scope.launch { delay(1200) fromPlayer = false intent.removeExtra("fromPlayer") if (!showUGCVideoInfo) { context.finish() } } } if (!fromPlayer) { context.finish() } } }.onFailure { val errorMessage = it.localizedMessage val isVideoNotFound = when (Prefs.apiType) { ApiType.Web -> errorMessage == "啥都木有" ApiType.App -> errorMessage == "访问权限不足" } logger.fInfo { "Get video info failed: ${it.stackTraceToString()}" } if (!isVideoNotFound || !Prefs.enableProxy) { withContext(Dispatchers.Main) { tip = it.localizedMessage ?: "未知错误" } return@onFailure } withContext(Dispatchers.Main) { videoDetailViewModel.state = VideoInfoState.Loading } logger.fInfo { "Trying get video info through proxy server" } runCatching { val seasonId = BiliPlusHttpApi.getSeasonIdByAvid(aid) logger.info { "Get season id from biliplus: $seasonId" } seasonId?.let { logger.fInfo { "Redirect to season $seasonId" } SeasonInfoActivity.actionStart( context = context, seasonId = seasonId, proxyArea = ProxyArea.HongKong ) context.finish() } ?: let { withContext(Dispatchers.Main) { tip = "视频不存在" videoDetailViewModel.state = VideoInfoState.Error } } }.onFailure { e -> logger.fWarn { "Redirect failed: ${e.stackTraceToString()}" } withContext(Dispatchers.Main) { tip = e.localizedMessage ?: "未知错误" videoDetailViewModel.state = VideoInfoState.Error } } } } } } LaunchedEffect(videoDetailViewModel.videoDetail) { //如果是从剧集页跳转回来的,那就不需要再跳转到剧集页了 if (fromSeason || !showUGCVideoInfo) return@LaunchedEffect videoDetailViewModel.videoDetail?.let { if (it.redirectToEp) { runCatching { logger.fInfo { "Redirect to ep ${it.epid}" } SeasonInfoActivity.actionStart( context = context, epId = it.epid, proxyArea = proxyArea ) context.finish() }.onFailure { logger.fWarn { "Redirect failed: ${it.stackTraceToString()}" } } } else { logger.fInfo { "No redirection required" } defaultFocusRequester.requestFocus(scope) } } } // 确保页面显示时封面获得焦点 LaunchedEffect(videoDetailViewModel.videoDetail, fromSeason, showUGCVideoInfo, fromPlayer) { if (videoDetailViewModel.videoDetail != null && !videoDetailViewModel.videoDetail!!.redirectToEp && !fromSeason && showUGCVideoInfo && !fromPlayer ) { // 延迟一小段时间确保UI完全渲染 delay(300) defaultFocusRequester.requestFocus(scope) } } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) { paused = true } else if (event == Lifecycle.Event.ON_RESUME) { // 如果 pause==true 那可能是从播放页返回回来的,此时更新历史记录 if (paused) updateHistory() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } if (videoDetailViewModel.videoDetail == null || videoDetailViewModel.videoDetail?.redirectToEp == true || fromSeason || !showUGCVideoInfo || fromPlayer) { Box( modifier = Modifier .fillMaxSize() .ifElse(!showUGCVideoInfo, Modifier.background(Color.Black)), contentAlignment = Alignment.Center ) { if (tip == "Loading") { LoadingTip() } else { Text( text = tip, fontSize = 20.sp ) } } } else { Scaffold( modifier = modifier ) { innerPadding -> Box( Modifier.padding(innerPadding) ) { // 图片加载成功后,动画 alpha,从 0 -> 0.6f val bgLoaded = remember { mutableStateOf(false) } val animatedAlpha by animateFloatAsState( targetValue = if (bgLoaded.value) 0.6f else 0f, animationSpec = tween(durationMillis = 500) ) AsyncImage( modifier = Modifier.fillMaxSize(), model = ImageRequest.Builder(LocalContext.current) .data(videoDetailViewModel.videoDetail?.cover) .transformations(BlurTransformation(LocalContext.current, 20f, 5f)) .build(), contentDescription = null, contentScale = ContentScale.Crop, alpha = animatedAlpha, onSuccess = { bgLoaded.value = true }, onError = { bgLoaded.value = false } ) LazyColumn( modifier = Modifier .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown) { when (event.key) { Key.DirectionUp -> { scope.launch { lazyListState.animateScrollBy(-200f) } } Key.DirectionDown -> { scope.launch { lazyListState.animateScrollBy(200f) } } } } return@onKeyEvent false }, state = lazyListState, contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (containsVerticalScreenVideo) { ArgueTip(text = stringResource(R.string.video_info_argue_tip_vertical_screen)) } if (videoDetailViewModel.videoDetail?.argueTip != null) { ArgueTip(text = videoDetailViewModel.videoDetail!!.argueTip!!) } } } item { VideoInfoData( defaultFocusRequester = defaultFocusRequester, videoDetail = videoDetailViewModel.videoDetail!!, showFollowButton = showFollowButton, isFollowing = isFollowing, tags = videoDetailViewModel.videoDetail!!.tags, isFavorite = favorited, favoriteFolderIds = videoInFavoriteFolderIds, onClickCover = { logger.fInfo { "Click video cover" } var title = "" var partTitle = "" //set video list if (videoDetailViewModel.videoDetail?.ugcSeason != null) { // 合集 if (videoDetailViewModel.videoDetail!!.ugcSeason!!.sections.size == 1) { // 只有一个分组 updateUgcSeasonSectionVideoList(0) } else { // 多个组,找默认播放哪个组的 val cid = videoDetailViewModel.videoDetail!!.pages.first().cid val sectionIndex = videoDetailViewModel.videoDetail!!.ugcSeason!!.sections .indexOfFirst { section -> section.episodes.any { it.cid == cid || it.pages.any { it.cid == cid } } } val section = videoDetailViewModel.videoDetail!!.ugcSeason!!.sections.getOrNull( sectionIndex ) title = if (section?.title == "正片") section.episodes.find { it.cid == cid }!!.title else section?.title ?: "" partTitle = if (section?.title == "正片") "" else section?.episodes?.find { it.cid == cid }!!.title updateUgcSeasonSectionVideoList(sectionIndex) } } else { // 分 p val partVideoList = videoDetailViewModel.videoDetail!!.pages.mapIndexed { index, videoPage -> VideoListPart( aid = videoDetailViewModel.videoDetail!!.aid, cid = videoPage.cid, title = videoDetailViewModel.videoDetail!!.title, partTitle = if (videoDetailViewModel.videoDetail!!.pages.size == 1) "" else videoPage.title, index = index, cover = videoDetailViewModel.videoDetail!!.cover, duration = videoPage.duration, ) } videoInfoRepository.videoList.clear() videoInfoRepository.videoList.addAll(partVideoList) } val lastPlayedPage = videoDetailViewModel.videoDetail!!.pages.find { it.cid == lastPlayedCid } val playPage = lastPlayedPage ?: videoDetailViewModel.videoDetail!!.pages.first() launchPlayerActivity( context = context, avid = videoDetailViewModel.videoDetail!!.aid, cid = playPage.cid, title = if (title.isNotEmpty()) title else videoDetailViewModel.videoDetail!!.title, partTitle = if (partTitle.isNotEmpty()) partTitle else if (videoDetailViewModel.videoDetail!!.pages.size == 1) "" else playPage.title, played = if (playPage.cid == lastPlayedCid) lastPlayedTime * 1000 else 0, fromSeason = false, isVerticalVideo = videoDetailViewModel.videoDetail!!.pages.first().dimension.isVertical, playerIconIdle = videoDetailViewModel.videoDetail!!.playerIcon?.idle ?: "", playerIconMoving = videoDetailViewModel.videoDetail!!.playerIcon?.moving ?: "", play = videoDetailViewModel.videoDetail!!.stat.view, danmaku = videoDetailViewModel.videoDetail!!.stat.danmaku, like = videoDetailViewModel.videoDetail!!.stat.like, coin = videoDetailViewModel.videoDetail!!.stat.coin, favorite = videoDetailViewModel.videoDetail!!.stat.favorite, upName = videoDetailViewModel.videoDetail!!.author.name, upId = videoDetailViewModel.videoDetail!!.author.mid, upFace = videoDetailViewModel.videoDetail!!.author.face, pubTime = videoDetailViewModel.videoDetail!!.publishDate.formatPubTimeString() ) }, onClickUp = { UpInfoActivity.actionStart( context, mid = videoDetailViewModel.videoDetail!!.author.mid, name = videoDetailViewModel.videoDetail!!.author.name, face = videoDetailViewModel.videoDetail!!.author.face ) }, onAddFollow = { addFollow { success -> screenScope.launch(Dispatchers.Main) { if (success) { "关注成功".toast(context) } else { "关注失败".toast(context) } } } }, onDelFollow = { delFollow { success -> screenScope.launch(Dispatchers.Main) { if (success) { "已取消关注".toast(context) } else { "取消关注失败".toast(context) } } } }, onClickTip = { tag -> TagActivity.actionStart( context = context, tagId = tag.id, tagName = tag.name ) }, onAddToDefaultFavoriteFolder = { addVideoToDefaultFavoriteFolder() favorited = true "已添加到默认收藏夹".toast(context) }, onUpdateFavoriteFolders = { updateVideoFavoriteData(it) favorited = it.isNotEmpty() videoInFavoriteFolderIds.swapList(it) if (it.isNotEmpty()) { "收藏成功".toast(context) } else { "已取消收藏".toast(context) } }, isLike = liked, onAddLike = { screenScope.launch { if (!liked) { if (addVideoLike()) { liked = true "点赞成功".toast(context) } else { "点赞失败".toast(context) } } } }, onDelLike = { screenScope.launch { if (liked) { if (delVideoLike()) { liked = false "已取消点赞".toast(context) } else { "取消点赞失败".toast(context) } } } }, isCoin = isCoin, onAddCoin = { screenScope.launch { if (!isCoin) { if (addVideoCoin()) { isCoin = true "投币成功".toast(context) } else { "投币失败".toast(context) } } } }, onShowDescription = { showDescriptionDialog = true }, onShowComment = { showCommentPanel = true }, commentButtonFocusRequester = commentButtonFocusRequester ) } if (videoDetailViewModel.videoDetail?.ugcSeason == null) { item { VideoPartRow( pages = videoDetailViewModel.videoDetail?.pages ?: emptyList(), lastPlayedCid = lastPlayedCid, lastPlayedTime = lastPlayedTime, enablePartListDialog = (videoDetailViewModel.videoDetail?.pages?.size ?: 0) > 5, onClick = { cid -> logger.fInfo { "Click video part: [av:${videoDetailViewModel.videoDetail?.aid}, bv:${videoDetailViewModel.videoDetail?.bvid}, cid:$cid]" } launchPlayerActivity( context = context, avid = videoDetailViewModel.videoDetail!!.aid, cid = cid, title = videoDetailViewModel.videoDetail!!.title, partTitle = videoDetailViewModel.videoDetail!!.pages.find { it.cid == cid }!!.title, played = if (cid == lastPlayedCid) lastPlayedTime * 1000 else 0, fromSeason = false, isVerticalVideo = videoDetailViewModel.videoDetail!!.pages.find { it.cid == cid }!!.dimension.isVertical, playerIconIdle = videoDetailViewModel.videoDetail!!.playerIcon?.idle ?: "", playerIconMoving = videoDetailViewModel.videoDetail!!.playerIcon?.moving ?: "", play = videoDetailViewModel.videoDetail!!.stat.view, danmaku = videoDetailViewModel.videoDetail!!.stat.danmaku, like = videoDetailViewModel.videoDetail!!.stat.like, coin = videoDetailViewModel.videoDetail!!.stat.coin, favorite = videoDetailViewModel.videoDetail!!.stat.favorite, upName = videoDetailViewModel.videoDetail!!.author.name, upId = videoDetailViewModel.videoDetail!!.author.mid, upFace = videoDetailViewModel.videoDetail!!.author.face, pubTime = videoDetailViewModel.videoDetail!!.publishDate.formatPubTimeString() ) } ) } } else { itemsIndexed( items = videoDetailViewModel.videoDetail?.ugcSeason!!.sections, key = { index, section -> "$index-section-${section.title}-${section.episodes.firstOrNull()?.aid ?: index}-${section.episodes.firstOrNull()?.cid ?: 0}" } ) { index, section -> VideoUgcSeasonRow( title = section.title, episodes = section.episodes, lastPlayedCid = lastPlayedCid, lastPlayedTime = lastPlayedTime, intentAid = intentAid, enableUgcListDialog = section.episodes.size > 5, onClickEp = { aid, cid -> logger.fInfo { "Click ugc season episode: [av:${videoDetailViewModel.videoDetail?.aid}, bv:${videoDetailViewModel.videoDetail?.bvid}, cid:$cid]" } updateUgcSeasonSectionVideoList(index) val sectionTitle = videoDetailViewModel.videoDetail?.ugcSeason?.sections?.getOrNull( index )?.title val episode = section.episodes.find { it.cid == cid } launchPlayerActivity( context = context, avid = aid, cid = cid, title = if (sectionTitle == "正片") episode!!.title else sectionTitle ?: videoDetailViewModel.videoDetail?.ugcSeason?.title ?: "", partTitle = if (sectionTitle == "正片") if (episode!!.pages.size > 1) episode.pages.first().title else "" else episode!!.title, played = if (cid == lastPlayedCid) lastPlayedTime * 1000 else 0, fromSeason = false, isVerticalVideo = if (sectionTitle == "正片" && episode!!.pages.size > 1) episode.pages.first().dimension.isVertical else videoDetailViewModel.videoDetail!!.pages.first().dimension.isVertical, playerIconIdle = videoDetailViewModel.videoDetail!!.playerIcon?.idle ?: "", playerIconMoving = videoDetailViewModel.videoDetail!!.playerIcon?.moving ?: "", play = videoDetailViewModel.videoDetail!!.stat.view, danmaku = videoDetailViewModel.videoDetail!!.stat.danmaku, like = videoDetailViewModel.videoDetail!!.stat.like, coin = videoDetailViewModel.videoDetail!!.stat.coin, favorite = videoDetailViewModel.videoDetail!!.stat.favorite, upName = videoDetailViewModel.videoDetail!!.author.name, upId = videoDetailViewModel.videoDetail!!.author.mid, upFace = videoDetailViewModel.videoDetail!!.author.face, pubTime = videoDetailViewModel.videoDetail!!.publishDate.formatPubTimeString() ) }, onClickEpPart = { episode, cid -> logger.fInfo { "Click ugc season episode part: [av:${videoDetailViewModel.videoDetail?.aid}, bv:${videoDetailViewModel.videoDetail?.bvid}, cid:$cid]" } val sectionTitle = videoDetailViewModel.videoDetail?.ugcSeason?.sections?.getOrNull( index )?.title launchPlayerActivity( context = context, avid = episode.aid, cid = cid, title = if (!sectionTitle.isNullOrEmpty()) episode.title else videoDetailViewModel.videoDetail!!.title, partTitle = episode.pages.find { it.cid == cid }!!.title, played = if (cid == lastPlayedCid) lastPlayedTime * 1000 else 0, fromSeason = false, isVerticalVideo = if (!sectionTitle.isNullOrEmpty()) episode.pages.find { it.cid == cid }!!.dimension.isVertical else videoDetailViewModel.videoDetail!!.pages.find { it.cid == cid }!!.dimension.isVertical, playerIconIdle = videoDetailViewModel.videoDetail!!.playerIcon?.idle ?: "", playerIconMoving = videoDetailViewModel.videoDetail!!.playerIcon?.moving ?: "", play = videoDetailViewModel.videoDetail!!.stat.view, danmaku = videoDetailViewModel.videoDetail!!.stat.danmaku, like = videoDetailViewModel.videoDetail!!.stat.like, coin = videoDetailViewModel.videoDetail!!.stat.coin, favorite = videoDetailViewModel.videoDetail!!.stat.favorite, upName = videoDetailViewModel.videoDetail!!.author.name, upId = videoDetailViewModel.videoDetail!!.author.mid, upFace = videoDetailViewModel.videoDetail!!.author.face, pubTime = videoDetailViewModel.videoDetail!!.publishDate.formatPubTimeString() ) } ) } } if (videoDetailViewModel.relatedVideos.isNotEmpty()) { item { VideosRow( header = stringResource(R.string.video_info_related_video_title), videos = videoDetailViewModel.relatedVideos, showMore = {}, onOpenSeasonInfo = { videoData -> SeasonInfoActivity.actionStart( context = context, epId = videoData.epId!!, proxyArea = ProxyArea.checkProxyArea(videoData.title) ) }, onOpenVideoInfo = { videoData -> VideoInfoActivity.actionStart(context, videoData.avid) } ) } } } } } } VideoDescriptionDialog( show = showDescriptionDialog, onHideDialog = { showDescriptionDialog = false }, description = videoDetailViewModel.videoDetail?.description ?: "" ) // 计算评论面板的初始 episode id(用于 UGC 合集) val commentInitialEpisodeId = remember(lastPlayedCid, intentAid, videoDetailViewModel.videoDetail?.ugcSeason) { val sections = videoDetailViewModel.videoDetail?.ugcSeason?.sections ?: return@remember -1 val allEpisodes = sections.flatMap { it.episodes } // 优先使用历史记录对应的 episode if (lastPlayedCid != 0L) { allEpisodes.find { ep -> ep.cid == lastPlayedCid || ep.pages.any { it.cid == lastPlayedCid } }?.id?.let { return@remember it } } // 没有历史记录时,使用与 intentAid 匹配的 episode if (intentAid != 0L) { allEpisodes.find { it.aid == intentAid }?.id?.let { return@remember it } } -1 } CommentPanel( show = showCommentPanel, oid = videoDetailViewModel.videoDetail?.aid ?: 0L, onHide = { showCommentPanel = false }, sections = videoDetailViewModel.videoDetail?.ugcSeason?.sections ?: emptyList(), initialEpisodeId = commentInitialEpisodeId ) // 浮层关闭后,焦点返回评论按钮 LaunchedEffect(showCommentPanel) { if (!showCommentPanel) { commentButtonFocusRequester.requestFocus() } } } @Composable fun ArgueTip( modifier: Modifier = Modifier, text: String ) { Surface( modifier = modifier .fillMaxWidth() .padding(horizontal = 36.dp), colors = SurfaceDefaults.colors( containerColor = Color.Yellow.copy(alpha = 0.2f), contentColor = Color.Yellow ), shape = MaterialTheme.shapes.small ) { Row( modifier = Modifier.padding( horizontal = 16.dp, vertical = 8.dp ), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Rounded.Warning, contentDescription = null, tint = Color.Yellow ) Text(text = text) } } } @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun VideoInfoData( modifier: Modifier = Modifier, defaultFocusRequester: FocusRequester, videoDetail: VideoDetail, showFollowButton: Boolean, isFollowing: Boolean, tags: List, isFavorite: Boolean, favoriteFolderIds: List = emptyList(), onClickCover: () -> Unit, onClickUp: () -> Unit, onAddFollow: () -> Unit, onDelFollow: () -> Unit, onClickTip: (Tag) -> Unit, onAddToDefaultFavoriteFolder: () -> Unit, onUpdateFavoriteFolders: (List) -> Unit, isLike: Boolean, onAddLike: () -> Unit = {}, onDelLike: () -> Unit = {}, isCoin: Boolean = false, onAddCoin: () -> Unit = {}, onShowDescription: () -> Unit = {}, onShowComment: () -> Unit = {}, commentButtonFocusRequester: FocusRequester ) { // val localDensity = LocalDensity.current // var heightIs by remember { mutableStateOf(0.dp) } val isLogin by remember { mutableStateOf(Prefs.isLogin) } // var coverHasFocus by remember { mutableStateOf(false) } val videoDuration = videoDetail.pages.sumOf { it.duration }.takeIf { videoDetail.pages.isNotEmpty() } ?: 0 Row( modifier = modifier .padding(start = 36.dp, end = 36.dp, top = 12.dp, bottom = 18.dp), ) { Surface( modifier = Modifier .focusRequester(defaultFocusRequester) .width(260.dp) .aspectRatio(1.6f) // .onGloballyPositioned { coordinates -> // heightIs = with(localDensity) { coordinates.size.height.toDp() } // } // .onFocusChanged { coverHasFocus = it.hasFocus } .padding(4.dp), onClick = onClickCover, shape = ClickableSurfaceDefaults.shape( shape = MaterialTheme.shapes.medium, ), scale = ClickableSurfaceDefaults.scale(scale = 1f, focusedScale = 1.05f), border = ClickableSurfaceDefaults.border( focusedBorder = Border( border = BorderStroke( width = 3.dp, color = MaterialTheme.colorScheme.border ), shape = MaterialTheme.shapes.medium ) ) ) { AsyncImage( modifier = Modifier .fillMaxSize(), // model = if (videoDetail.ugcSeason != null) videoDetail.ugcSeason!!.cover else videoDetail.cover, model = videoDetail.cover, contentDescription = null, contentScale = ContentScale.Crop ) if (videoDetail.isChargingArc) { Text( modifier = Modifier .padding(6.dp) .align(Alignment.TopEnd) .background( color = Color.Black.copy(0.3f), shape = MaterialTheme.shapes.extraSmall ) .padding(all = 2.dp), text = "⚡${videoDetail.chargingArcBadge}", style = MaterialTheme.typography.bodyMedium, color = Color.White ) } Box( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .height(48.dp) .clip( RoundedCornerShape( topStart = 0.dp, topEnd = 0.dp, bottomStart = 12.dp, bottomEnd = 12.dp ) ) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.8f) ) ) ) ) { Row( modifier = Modifier .fillMaxSize() .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = Color.White ) Text( text = with(videoDetail.stat.view) { if (this >= 10000) "${this / 10000}万" else "$this" }, style = MaterialTheme.typography.bodySmall, color = Color.White ) Spacer(modifier = Modifier.width(6.dp)) Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = Color.White ) Text( text = with(videoDetail.stat.danmaku) { if (this >= 10000) "${this / 10000}万" else "$this" }, style = MaterialTheme.typography.bodySmall, color = Color.White ) Spacer(Modifier.weight(1f)) Text( text = (videoDuration * 1000L).formatHourMinSec(), color = Color.White, style = MaterialTheme.typography.bodySmall ) } } } Spacer(modifier = Modifier.width(24.dp)) Column( modifier = Modifier .fillMaxWidth(), // .height(heightIs), verticalArrangement = Arrangement.spacedBy(20.dp) ) { // 基本信息 Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = videoDetail.title, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White ) Row( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { CompositionLocalProvider( LocalTextStyle provides MaterialTheme.typography.labelMedium ) { Text(text = "${videoDetail.stat.like} 点赞") Text(text = "·") Text(text = "${videoDetail.stat.coin} 投币") Text(text = "·") Text(text = "${videoDetail.stat.favorite} 收藏") Text(text = "·") Text(text = videoDetail.publishDate.formatPubTimeString()) } } LazyRow( modifier = Modifier .fillMaxWidth() .offset(x = (-3).dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), contentPadding = PaddingValues(horizontal = 4.dp) ) { if (isLogin) { item { LikeButton( modifier = Modifier .height(32.dp), // 设置高度 isLike = isLike, onToggleLike = { if (isLike) { onDelLike() } else { onAddLike() } } ) } item { FavoriteButton( modifier = Modifier .height(32.dp), // 设置高度 isFavorite = isFavorite, favoriteFolderIds = favoriteFolderIds, onAddToDefaultFavoriteFolder = onAddToDefaultFavoriteFolder, onUpdateFavoriteFolders = onUpdateFavoriteFolders ) } item { CoinButton( modifier = Modifier .height(32.dp), // 设置高度 isCoin = isCoin, onAddCoin = { onAddCoin() } ) } } item { UpButton( name = videoDetail.author.name, followed = isFollowing, showFollowButton = showFollowButton, onClickUp = onClickUp, onAddFollow = onAddFollow, onDelFollow = onDelFollow ) } // 简介按钮 if (videoDetail.description.isNotBlank()) { item { Row( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(Color.White.copy(alpha = 0.2f)) .focusedBorder(MaterialTheme.shapes.small) .padding(horizontal = 4.dp) .clickable { onShowDescription() } .height(30.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "简介>>", color = Color.White ) } } } // 评论按钮 item { Row( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(Color.White.copy(alpha = 0.2f)) .focusedBorder(MaterialTheme.shapes.small) .padding(horizontal = 4.dp) .focusRequester(commentButtonFocusRequester) .clickable { onShowComment() } .height(30.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "评论>>", color = Color.White ) } } } } // 标签列表 LazyRow( modifier = Modifier .fillMaxWidth() .offset(x = (-2).dp, y = (-2).dp), contentPadding = PaddingValues(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { itemsIndexed( items = tags, key = { index, tag -> "$index-tag-${tag.name}" } ) { _, tag -> SuggestionChip(onClick = { onClickTip(tag) }) { Text(text = tag.name) } } } } } } @Composable private fun UpButton( modifier: Modifier = Modifier, name: String, followed: Boolean, showFollowButton: Boolean = false, onClickUp: () -> Unit, onAddFollow: () -> Unit, onDelFollow: () -> Unit ) { val view = LocalView.current val isLogin by remember { mutableStateOf(if (!view.isInEditMode) Prefs.isLogin else true) } Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Row( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(Color.White.copy(alpha = 0.2f)) .focusedBorder(MaterialTheme.shapes.small) .padding(4.dp) .clickable { onClickUp() }, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { UpIcon(color = Color.White) Text(text = name, color = Color.White) } if (isLogin && showFollowButton) { Row( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(Color.White.copy(alpha = 0.2f)) .focusedBorder(MaterialTheme.shapes.small) .padding(horizontal = 4.dp, vertical = 3.dp) .clickable { if (followed) onDelFollow() else onAddFollow() } .animateContentSize(), verticalAlignment = Alignment.CenterVertically, ) { if (followed) { Icon( imageVector = Icons.Rounded.Done, contentDescription = null, tint = Color.White ) Text( text = stringResource(R.string.video_info_followed), color = Color.White ) } else { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, tint = Color.White ) Text(text = stringResource(R.string.video_info_follow), color = Color.White) } } } } } @Composable fun VideoDescriptionDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, description: String ) { val state = rememberLazyListState() val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) { focusRequester.requestFocus() } } if (show) { TvAlertDialog( modifier = modifier .fillMaxWidth(0.8f), onDismissRequest = { onHideDialog() }, properties = DialogProperties(usePlatformDefaultWidth = false), title = { Text( text = stringResource(R.string.video_info_description_title), color = Color.White ) }, text = { LazyColumn( modifier = Modifier .heightIn(max = 320.dp) .focusable() .focusRequester(focusRequester) .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown) { when (event.key) { Key.DirectionUp -> { scope.launch { state.animateScrollBy(-state.layoutInfo.viewportSize.height / 3f) } true } Key.DirectionDown -> { scope.launch { state.animateScrollBy(state.layoutInfo.viewportSize.height / 3f) } true } else -> false } } else { false } }, state = state ) { item { Text(text = description) } } }, confirmButton = {} ) } } @Composable private fun VideoPartButton( modifier: Modifier = Modifier, index: Int, title: String, duration: Int, cover: String? = null, pubDate: Long = 0L, played: Int = 0, isLastPlayed: Boolean = false, isCurrentIntent: Boolean = false, type: VideoPartType = VideoPartType.Part, onClick: () -> Unit ) { var hasFocus by remember { mutableStateOf(false) } val hasCover = !cover.isNullOrBlank() Surface( modifier = modifier.onFocusChanged { hasFocus = it.hasFocus }, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), focusedContainerColor = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.9f), focusedContentColor = MaterialTheme.colorScheme.background ), scale = ClickableSurfaceDefaults.scale(scale = 1f, focusedScale = 1f), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = { onClick() } ) { val borderStroke = when { hasFocus -> BorderStroke(2.dp, MaterialTheme.colorScheme.border) isCurrentIntent -> BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) else -> null } Box( modifier = Modifier .height(72.dp) .width(if (hasCover) 240.dp else 200.dp) .then( if (borderStroke != null) Modifier.border( border = borderStroke, shape = MaterialTheme.shapes.medium ) else Modifier ) ) { Row( modifier = Modifier .fillMaxSize() ) { if (hasCover) { AsyncImage( model = cover.resizedImageUrl(ImageSize.UgcEpisodeCover), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxHeight() .aspectRatio(4f / 3f) .clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp)) ) } Column( modifier = Modifier .fillMaxHeight() .weight(1f) .padding(horizontal = 8.dp, vertical = 6.dp), verticalArrangement = Arrangement.SpaceBetween ) { Text( text = buildAnnotatedString { if (isLastPlayed) { withStyle(style = SpanStyle(color = Color(0xFFE39B17))) { append("继续播放 ") } } append(when (type) { VideoPartType.Episode -> "EP" VideoPartType.Part -> "P" } + "$index $title") }, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium.copy( lineHeight = 19.sp ) ) Row( modifier = Modifier.padding(top = 2.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = (duration * 1000L).formatHourMinSec(), fontSize = 11.sp, color = LocalContentColor.current.copy(alpha = 0.9f), maxLines = 1 ) if (pubDate > 0L) { Text( text = Date(pubDate * 1000L).formatPubTimeString(), fontSize = 11.sp, color = LocalContentColor.current.copy(alpha = 0.9f), maxLines = 1 ) } } } } if (played != 0) { val progressFraction = if (played < 0) 1f else (played / duration.toFloat()).coerceIn(0f, 1f) val sliderColors = SliderDefaults.colors() Box( modifier = Modifier .align(Alignment.BottomStart) .height(5.dp) .fillMaxWidth(progressFraction) .background(Color(0xFFE39B17)) ) {} } } } } private enum class VideoPartType { Episode, Part } @Composable private fun VideoPartRowButton( modifier: Modifier = Modifier, hasFocus: Boolean = true, onClick: () -> Unit ) { val scale by animateFloatAsState( targetValue = if (hasFocus) 1f else 0.4f, label = "button scale", animationSpec = tween( durationMillis = 120 ) ) Surface( modifier = modifier, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.small), border = ClickableSurfaceDefaults.border( focusedBorder = Border( border = BorderStroke(2.dp, MaterialTheme.colorScheme.border), shape = MaterialTheme.shapes.small ) ), onClick = onClick ) { Box( modifier = Modifier .size(width = (40 * scale).dp, height = (42 * scale).dp), contentAlignment = Alignment.Center ) { Icon( modifier = Modifier .size(32.dp) .rotate(90f), imageVector = Icons.Rounded.ViewModule, contentDescription = null ) } } } @Composable fun VideoPartRow( modifier: Modifier = Modifier, pages: List, lastPlayedCid: Long = 0, lastPlayedTime: Int = 0, enablePartListDialog: Boolean = false, nested: Boolean = false, subtitle: String = "", onClick: (cid: Long) -> Unit ) { val focusRequester = remember { FocusRequester() } var hasFocus by remember { mutableStateOf(false) } var showPartListDialog by remember { mutableStateOf(false) } val listState = rememberLazyListState() val titleFontSize by animateFloatAsState( targetValue = if (hasFocus) 30f else 14f, label = "title font size", animationSpec = tween( durationMillis = 120 ) ) // 滚动到有历史记录的那一集 LaunchedEffect(lastPlayedCid, pages) { if (lastPlayedCid != 0L && pages.isNotEmpty()) { val index = pages.indexOfFirst { it.cid == lastPlayedCid } if (index > 0) { listState.scrollToItem(index) } } } Column( modifier = modifier .ifElse(!nested, Modifier.padding(start = 26.dp)) .onFocusChanged { hasFocus = it.hasFocus }, verticalArrangement = Arrangement.SpaceBetween ) { Row( modifier = Modifier.padding(start = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = stringResource(R.string.video_info_part_row_title) + (" - $subtitle".takeIf { subtitle.isNotBlank() } ?: ""), fontSize = titleFontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) if (enablePartListDialog) { VideoPartRowButton( hasFocus = hasFocus, onClick = { showPartListDialog = true } ) } } LazyRow( modifier = Modifier .padding(top = 4.dp) .focusRestorer(focusRequester), state = listState, contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { itemsIndexed(items = pages, key = { index, page -> "$index-page-${page.cid}" }) { index, page -> VideoPartButton( modifier = Modifier .ifElse(index == 0, Modifier.focusRequester(focusRequester)), index = index + 1, title = page.title, played = if (page.cid == lastPlayedCid) lastPlayedTime else 0, isLastPlayed = page.cid == lastPlayedCid, duration = page.duration, onClick = { onClick(page.cid) } ) } } } VideoPartListDialog( show = showPartListDialog, onHideDialog = { showPartListDialog = false }, pages = pages, lastPlayedCid = lastPlayedCid, lastPlayedTime = lastPlayedTime, title = "分 P 列表", onClick = onClick ) } @Composable fun VideoUgcSeasonRow( modifier: Modifier = Modifier, title: String, episodes: List, lastPlayedCid: Long = 0, lastPlayedTime: Int = 0, intentAid: Long = 0, enableUgcListDialog: Boolean = false, onClickEp: (avid: Long, cid: Long) -> Unit, onClickEpPart: (episode: Episode, cid: Long) -> Unit ) { val focusRequester = remember { FocusRequester() } var hasFocus by remember { mutableStateOf(false) } var showUgcListDialog by remember { mutableStateOf(false) } val listState = rememberLazyListState() val titleFontSize by animateFloatAsState( targetValue = if (hasFocus) 30f else 14f, label = "title font size", animationSpec = tween( durationMillis = 120 ) ) var focusingEpisode by remember { mutableStateOf(null) } // 滚动到有历史记录的那一集,如果没有历史记录则滚动到与 intentAid 相同的视频 LaunchedEffect(lastPlayedCid, intentAid, episodes) { if (episodes.isEmpty()) return@LaunchedEffect val index = if (lastPlayedCid != 0L) { // 优先使用历史记录 episodes.indexOfFirst { it.cid == lastPlayedCid || it.pages.any { page -> page.cid == lastPlayedCid } } } else if (intentAid != 0L) { // 没有历史记录时,滚动到与 intentAid 相同的视频 episodes.indexOfFirst { it.aid == intentAid } } else { -1 } if (index > 0) { listState.scrollToItem(index) } } Column( modifier = modifier .padding(start = 26.dp) .onFocusChanged { hasFocus = it.hasFocus }, verticalArrangement = Arrangement.SpaceBetween ) { Row( modifier = Modifier.padding(start = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = title, fontSize = titleFontSize.sp ) if (enableUgcListDialog) { VideoPartRowButton( hasFocus = hasFocus, onClick = { showUgcListDialog = true } ) } } LazyRow( modifier = Modifier .padding(top = 4.dp) .focusRestorer(focusRequester), state = listState, contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { itemsIndexed( items = episodes, key = { index, episode -> "$index-episode-${episode.aid}-${episode.cid}" } ) { index, episode -> VideoPartButton( modifier = Modifier .ifElse(index == 0, Modifier.focusRequester(focusRequester)) .onFocusChanged { if (it.hasFocus) focusingEpisode = episode }, index = index + 1, title = episode.title, cover = episode.cover, pubDate = episode.pubDate, played = if (episode.cid == lastPlayedCid) lastPlayedTime else 0, isLastPlayed = episode.cid == lastPlayedCid || episode.pages.any { it.cid == lastPlayedCid }, isCurrentIntent = episode.aid == intentAid, duration = episode.duration, type = VideoPartType.Episode, onClick = { onClickEp(episode.aid, episode.cid) } ) } } AnimatedVisibility((focusingEpisode?.pages?.size ?: 0) > 1) { VideoPartRow( modifier = Modifier.padding(top = 8.dp), pages = focusingEpisode!!.pages, lastPlayedCid = lastPlayedCid, lastPlayedTime = lastPlayedTime, enablePartListDialog = (focusingEpisode?.pages?.size ?: 0) > 5, nested = true, onClick = { onClickEpPart(focusingEpisode!!, it) }, subtitle = focusingEpisode!!.title ) } } VideoUgcListDialog( show = showUgcListDialog, onHideDialog = { showUgcListDialog = false }, episodes = episodes, lastPlayedCid = lastPlayedCid, lastPlayedTime = lastPlayedTime, intentAid = intentAid, title = "合集列表", onClick = onClickEp ) } @Composable private fun VideoPartListDialog( modifier: Modifier = Modifier, show: Boolean, title: String, pages: List, lastPlayedCid: Long = 0, lastPlayedTime: Int = 0, onHideDialog: () -> Unit, onClick: (cid: Long) -> Unit ) { val scope = rememberCoroutineScope() var selectedTabIndex by remember { mutableIntStateOf(0) } val tabCount by remember { mutableIntStateOf(ceil(pages.size / 20.0).toInt()) } val selectedVideoPart = remember { mutableStateListOf() } val tabFocusRequester = remember { FocusRequester() } val tabRowFocusRequester = remember { FocusRequester() } val videoListFocusRequester = remember { FocusRequester() } val listState = rememberLazyGridState() LaunchedEffect(selectedTabIndex) { val fromIndex = selectedTabIndex * 20 var toIndex = (selectedTabIndex + 1) * 20 if (toIndex >= pages.size) { toIndex = pages.size } selectedVideoPart.swapListWithMainContext(pages.subList(fromIndex, toIndex)) } LaunchedEffect(show) { if (show && tabCount > 1) tabFocusRequester.requestFocus(scope) if (show && tabCount == 1) videoListFocusRequester.requestFocus(scope) } if (show) { TvAlertDialog( modifier = modifier, title = { Text(text = title) }, onDismissRequest = { onHideDialog() }, confirmButton = {}, properties = DialogProperties(usePlatformDefaultWidth = false), text = { Column( modifier = Modifier.size(600.dp, 330.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { TabRow( modifier = Modifier .onFocusChanged { if (it.hasFocus) { scope.launch(Dispatchers.Main) { listState.scrollToItem(0) } } } .focusRestorer() .focusRequester(tabRowFocusRequester), selectedTabIndex = selectedTabIndex, separator = { Spacer(modifier = Modifier.width(12.dp)) }, ) { for (i in 0 until tabCount) { Tab( modifier = if (i == 0) Modifier.focusRequester( tabFocusRequester ) else Modifier, selected = i == selectedTabIndex, onFocus = { selectedTabIndex = i }, ) { Text( text = "P${i * 20 + 1}-${(i + 1) * 20}", fontSize = 12.sp, color = LocalContentColor.current, modifier = Modifier.padding( horizontal = 16.dp, vertical = 6.dp ) ) } } } LazyVerticalGrid( modifier = Modifier .onBackPressed { if (tabCount > 1) tabRowFocusRequester.requestFocus() else onHideDialog() }, state = listState, columns = GridCells.Fixed(2), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = selectedVideoPart, key = { index, video -> "$index-video-${video.cid}" } ) { index, page -> val buttonModifier = if (index == 0) Modifier.focusRequester(videoListFocusRequester) else Modifier VideoPartButton( modifier = buttonModifier, index = page.index, title = page.title, played = if (page.cid == lastPlayedCid) lastPlayedTime else 0, isLastPlayed = page.cid == lastPlayedCid, duration = page.duration, onClick = { onClick(page.cid) } ) } } } } ) } } @Composable private fun VideoUgcListDialog( modifier: Modifier = Modifier, show: Boolean, title: String, episodes: List, lastPlayedCid: Long = 0, lastPlayedTime: Int = 0, intentAid: Long = 0, onHideDialog: () -> Unit, onClick: (avid: Long, cid: Long) -> Unit ) { val scope = rememberCoroutineScope() var selectedTabIndex by remember { mutableIntStateOf(0) } val tabCount by remember { mutableIntStateOf(ceil(episodes.size / 20.0).toInt()) } val selectedVideoPart = remember { mutableStateListOf() } val tabFocusRequester = remember { FocusRequester() } val tabRowFocusRequester = remember { FocusRequester() } val videoListFocusRequester = remember { FocusRequester() } val listState = rememberLazyGridState() LaunchedEffect(selectedTabIndex) { val fromIndex = selectedTabIndex * 20 var toIndex = (selectedTabIndex + 1) * 20 if (toIndex >= episodes.size) { toIndex = episodes.size } selectedVideoPart.swapListWithMainContext(episodes.subList(fromIndex, toIndex)) } LaunchedEffect(show) { if (show && tabCount > 1) tabFocusRequester.requestFocus(scope) if (show && tabCount == 1) videoListFocusRequester.requestFocus(scope) } if (show) { TvAlertDialog( modifier = modifier, title = { Text(text = title) }, onDismissRequest = { onHideDialog() }, confirmButton = {}, properties = DialogProperties(usePlatformDefaultWidth = false), text = { Column( modifier = Modifier.size(640.dp, 330.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { TabRow( modifier = Modifier .onFocusChanged { if (it.hasFocus) { scope.launch(Dispatchers.Main) { listState.scrollToItem(0) } } } .focusRestorer() .focusRequester(tabRowFocusRequester), selectedTabIndex = selectedTabIndex, separator = { Spacer(modifier = Modifier.width(12.dp)) }, ) { for (i in 0 until tabCount) { Tab( modifier = if (i == 0) Modifier.focusRequester( tabFocusRequester ) else Modifier, selected = i == selectedTabIndex, onFocus = { selectedTabIndex = i }, ) { Text( text = "EP${i * 20 + 1}-${(i + 1) * 20}", fontSize = 12.sp, color = LocalContentColor.current, modifier = Modifier.padding( horizontal = 16.dp, vertical = 6.dp ) ) } } } LazyVerticalGrid( modifier = Modifier .onBackPressed { if (tabCount > 1) tabRowFocusRequester.requestFocus() else onHideDialog() }, state = listState, columns = GridCells.Fixed(2), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = selectedVideoPart, key = { index, video -> "$index-video-${video.cid}" } ) { index, episode -> val buttonModifier = if (index == 0) Modifier.focusRequester(videoListFocusRequester) else Modifier VideoPartButton( modifier = buttonModifier, index = selectedTabIndex * 20 + index + 1, type = VideoPartType.Episode, title = episode.title, cover = episode.cover, pubDate = episode.pubDate, played = if (episode.cid == lastPlayedCid) lastPlayedTime else 0, isLastPlayed = episode.cid == lastPlayedCid || episode.pages.any { it.cid == lastPlayedCid }, isCurrentIntent = episode.aid == intentAid, duration = episode.duration, onClick = { onClick(episode.aid, episode.cid) } ) } } } } ) } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun VideoPartButtonShortTextPreview() { BVTheme { VideoPartButton( index = 2, title = "这是一段短文字", duration = 100, onClick = {} ) } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun VideoPartButtonLongTextPreview() { BVTheme { VideoPartButton( index = 2, title = "这可能是我这辈子距离梅西最近的一次", played = 23333, duration = 100, onClick = {} ) } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun VideoPartRowPreview() { val pages = remember { mutableStateListOf() } for (i in 0..10) { pages.add( VideoPage( cid = 1000L + i, index = i, title = "这可能是我这辈子距离梅西最近的一次", duration = 10, dimension = Dimension(0, 0) ) ) } BVTheme { VideoPartRow(pages = pages, onClick = {}) } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun UpButtonPreview() { var followed by remember { mutableStateOf(false) } BVTheme { UpButton( name = "12435678", followed = followed, onClickUp = { followed = !followed }, onAddFollow = {}, onDelFollow = {} ) } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun CoverPreview() { Box { AsyncImage( modifier = Modifier .fillMaxSize(), // model = if (videoDetail.ugcSeason != null) videoDetail.ugcSeason!!.cover else videoDetail.cover, model = "http://i2.hdslb.com/bfs/archive/af17fc07b8f735e822563cc45b7b5607a491dfff.jpg", contentDescription = null, contentScale = ContentScale.Crop ) Box( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .height(48.dp) .clip( RoundedCornerShape( topStart = 0.dp, topEnd = 0.dp, bottomStart = 16.dp, bottomEnd = 16.dp ) ) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, Color.Black.copy(alpha = 0.8f) ) ) ) ) { Row( modifier = Modifier .fillMaxSize() .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_play_count), contentDescription = null, tint = Color.White ) Text( text = "3009", style = MaterialTheme.typography.bodySmall, color = Color.White ) Spacer(modifier = Modifier.width(8.dp)) Icon( modifier = Modifier, painter = painterResource(id = R.drawable.ic_danmaku_count), contentDescription = null, tint = Color.White ) Text( text = "1099", style = MaterialTheme.typography.bodySmall, color = Color.White ) Spacer(Modifier.weight(1f)) Text( text = "12:34", color = Color.White, style = MaterialTheme.typography.bodySmall ) } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/VideoPlayerV3Screen.kt ================================================ package dev.aaa1115910.bv.tv.screens import android.app.Activity import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.tv.material3.Border import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.MaterialTheme import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.player.entity.LocalVideoPlayerConfigData import dev.aaa1115910.bv.player.danmaku.DanmakuView 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.PortraitVideoFixMode import dev.aaa1115910.bv.player.entity.PlayMode import dev.aaa1115910.bv.player.entity.Resolution import dev.aaa1115910.bv.player.entity.VideoListItemData import dev.aaa1115910.bv.entity.carddata.VideoCardData 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.tv.BvPlayer import dev.aaa1115910.bv.player.tv.controller.LiveViewerCountTip import dev.aaa1115910.bv.player.tv.controller.OnlineViewerCountTip import dev.aaa1115910.bv.player.tv.controller.SkipTip import dev.aaa1115910.bv.player.tv.controller.UserActionKey import dev.aaa1115910.bv.tv.activities.video.TagActivity import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.component.buttons.CoinButton import dev.aaa1115910.bv.tv.component.CommentPanel import dev.aaa1115910.bv.tv.component.DescriptionPanel import dev.aaa1115910.bv.tv.component.buttons.FavoriteButton import dev.aaa1115910.bv.tv.component.buttons.LikeButton import dev.aaa1115910.bv.tv.component.buttons.ToViewButton import dev.aaa1115910.bv.tv.manager.FollowStateManager import dev.aaa1115910.bv.tv.manager.PlayedAidsCache import dev.aaa1115910.bv.tv.manager.VideoUserActionManager import dev.aaa1115910.bv.tv.manager.VideoUserActionManager.getStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.util.formatHourMinSec import dev.aaa1115910.bv.util.swapList import dev.aaa1115910.bv.viewmodel.VideoPlayerV3ViewModel import dev.aaa1115910.bv.tv.component.GeetestTvVerifyDialog import dev.aaa1115910.biliapi.http.BiliHttpApi import dev.aaa1115910.bv.player.entity.NextVideoStrategy import dev.aaa1115910.bv.tv.component.videocard.TabbedVideosPanel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import org.koin.androidx.compose.koinViewModel @Composable fun VideoPlayerV3Screen( modifier: Modifier = Modifier, playerViewModel: VideoPlayerV3ViewModel = koinViewModel(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } // 外部创建 DanmakuView,与 videoPlayer 一致的模式 val danmakuView = remember { DanmakuView(context).also { playerViewModel.danmakuView = it } } DisposableEffect(danmakuView) { onDispose { danmakuView?.release() } } // subscribe shared action state by aid val currentAid = playerViewModel.currentAid val sharedActionFlow = remember(currentAid) { getStateFlow(currentAid, Prefs.uid) } val sharedActionState by sharedActionFlow.collectAsState() val followStateMap by FollowStateManager.followStateMap.collectAsState() LaunchedEffect(followStateMap, playerViewModel.upId) { val currentUpId = playerViewModel.upId if (currentUpId > 0) { val result = FollowStateManager.ensureFollowState(currentUpId) if (result != null && playerViewModel.isFollowingUp != result) { playerViewModel.isFollowingUp = result } } } // 倒计时相关状态 var autoActionCountdownJob by remember { mutableStateOf(null) } var autoActionTipVisible by remember { mutableStateOf(false) } var autoActionTipText by remember { mutableStateOf("") } var skipNextKeyUpCancel by remember { mutableStateOf(false) } var showDebugInfo by remember { mutableStateOf(Prefs.playerShowDebugInfo) } // 在线观看人数状态 var onlineViewerCount by remember { mutableStateOf("") } var showOnlineViewerCountTip by remember { mutableStateOf(false) } var canShowViewerCountTip by remember { mutableStateOf(true) } var showLiveViewerCountTip by remember { mutableStateOf(false) } var viewerCountText by remember { mutableStateOf("") } // 评论面板状态 var showCommentPanel by remember { mutableStateOf(false) } // 简介面板状态 var showDescriptionPanel by remember { mutableStateOf(false) } // 焦点管理 val relatedVideosFocusRequester = remember { FocusRequester() } // 当显示相关视频时,自动将焦点转移到VideosRow的第一个卡片 LaunchedEffect(playerViewModel.showRelatedVideos) { if (playerViewModel.showRelatedVideos) { delay(300) kotlin.runCatching { relatedVideosFocusRequester.requestFocus() } } } // 获取在线观看人数 LaunchedEffect(playerViewModel.currentCid, playerViewModel.currentAid) { if (playerViewModel.currentCid > 0 && playerViewModel.currentAid > 0 && Prefs.showOnlineViewerCount > 0) { withContext(Dispatchers.IO) { try { val response = BiliHttpApi.getVideoOnlineTotal( cid = playerViewModel.currentCid, aid = playerViewModel.currentAid ) if (response.code == 0) { onlineViewerCount = response.data?.total ?: "" showOnlineViewerCountTip = true // 如果设置为 30 秒后隐藏,则自动隐藏 if (Prefs.showOnlineViewerCount == 1) { delay(30_000) showOnlineViewerCountTip = false } } } catch (e: Exception) { logger.warn(e) { "Failed to get online viewer count" } } } } else { onlineViewerCount = "" } } // 在线观看人数设置为30秒后隐藏或者始终显示,每 5 分钟刷新一次数据。虽然左下角隐藏,但播放器控制条中还要显示 LaunchedEffect(showOnlineViewerCountTip, Prefs.showOnlineViewerCount) { if (showOnlineViewerCountTip) { while (true) { delay(300_000) // 5 分钟 if (playerViewModel.currentCid > 0 && playerViewModel.currentAid > 0) { withContext(Dispatchers.IO) { try { val response = BiliHttpApi.getVideoOnlineTotal( cid = playerViewModel.currentCid, aid = playerViewModel.currentAid ) if (response.code == 0) { onlineViewerCount = response.data?.total ?: "" } } catch (e: Exception) { logger.warn(e) { "Failed to refresh online viewer count" } } } } } } } // 控制直播人气显示 LaunchedEffect(playerViewModel.isLive, Prefs.showLiveViewerCountTip, playerViewModel.livePopularityText) { if (playerViewModel.isLive && Prefs.showLiveViewerCountTip > 0 && playerViewModel.livePopularityText.isNotEmpty()) { showLiveViewerCountTip = true if (Prefs.showLiveViewerCountTip == 1) { delay(30_000) showLiveViewerCountTip = false } } else { showLiveViewerCountTip = false } } // 更新 viewerCountText LaunchedEffect(Prefs.showOnlineViewerCount, onlineViewerCount, Prefs.showOnlineViewerCount, playerViewModel.livePopularityText, playerViewModel.liveOnlineCount) { if (playerViewModel.isLive && Prefs.showOnlineViewerCount > 0) { if (playerViewModel.livePopularityText.isNotEmpty()) { viewerCountText = playerViewModel.livePopularityText } if (playerViewModel.liveOnlineCount.isNotEmpty()) { viewerCountText = viewerCountText + " · " + playerViewModel.liveOnlineCount } } else if (Prefs.showOnlineViewerCount > 0 && onlineViewerCount.isNotEmpty()) { viewerCountText = "$onlineViewerCount 人在看" } } // 处理back键,当推荐视频有焦点时隐藏推荐视频并将焦点返回到播放器 BackHandler(enabled = playerViewModel.showRelatedVideos) { playerViewModel.showRelatedVideos = false } 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, play = playerViewModel.play, danmaku = playerViewModel.danmaku, like = playerViewModel.like, coin = playerViewModel.coin, favorite = playerViewModel.favorite, upName = playerViewModel.upName, pubTime = playerViewModel.pubTime, fromSeason = playerViewModel.fromSeason, isFollowingUp = playerViewModel.isFollowingUp, isVerticalVideo = playerViewModel.isVerticalVideo, isLive = playerViewModel.isLive ), LocalVideoPlayerLogsData provides VideoPlayerLogsData( logs = playerViewModel.logs ), LocalVideoPlayerHistoryData provides VideoPlayerHistoryData( lastPlayed = playerViewModel.lastPlayed, ), LocalVideoPlayerPaymentData provides VideoPlayerPaymentData( needPay = playerViewModel.needPay, epid = playerViewModel.epid, showPreviewTip = playerViewModel.showPreviewTip, ), 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, currentVideoRotation = playerViewModel.currentVideoRotation, currentVideoSpeed = playerViewModel.currentPlaySpeed, currentAudio = playerViewModel.currentAudio, currentDanmakuEnabled = playerViewModel.currentDanmakuEnabled, currentDanmakuEnabledList = playerViewModel.currentDanmakuTypes, currentDanmakuScale = playerViewModel.currentDanmakuScale, currentDanmakuOpacity = playerViewModel.currentDanmakuOpacity, currentDanmakuArea = playerViewModel.currentDanmakuArea, currentDanmakuMask = playerViewModel.currentDanmakuMask, currentDanmakuRollingDurationFactor = playerViewModel.currentDanmakuRollingDurationFactor, currentDanmakuFilterLevel = playerViewModel.currentDanmakuFilterLevel, currentLiveDanmakuFilterLevel = playerViewModel.currentLiveDanmakuFilterLevel, currentSubtitleId = playerViewModel.currentSubtitleId, currentSubtitleData = playerViewModel.currentSubtitleData, currentSubtitleFontSize = playerViewModel.currentSubtitleFontSize, currentSubtitleBackgroundOpacity = playerViewModel.currentSubtitleBackgroundOpacity, currentSubtitleBottomPadding = playerViewModel.currentSubtitleBottomPadding, currentPlayMode = playerViewModel.currentPlayMode, incognitoMode = Prefs.incognitoMode, hasPreloadedVideoList = playerViewModel.preloadedVideoList.isNotEmpty(), hasRelatedVideos = playerViewModel.relatedVideos.isNotEmpty(), fromSeason = playerViewModel.fromSeason, showDanmaku = playerViewModel.showDanmaku, showRelatedVideos = playerViewModel.showRelatedVideos, showNextVideoBtn = !(playerViewModel.currentPlayMode == PlayMode.SingleVideo || playerViewModel.currentPlayMode == PlayMode.SingleLoop || (playerViewModel.currentPlayMode == PlayMode.Custom && Prefs.playerNextVideoStrategyOrder.split(",").none { !it.startsWith("-") })), defaultStartPosition = Prefs.playerDefaultStartPosition.toPlayerType(), clipInfoList = playerViewModel.clipInfoList, skipPgcIntroOutro = Prefs.skipPgcIntroOutro, isLive = playerViewModel.isLive, availableLiveQualities = playerViewModel.availableLiveQualities.toList(), currentLiveQn = playerViewModel.currentLiveQn, currentLiveQualityDescription = playerViewModel.currentLiveQualityDescription, currentLiveCodec = playerViewModel.currentLiveCodec, controllerButtonsOrder = Prefs.playerControllerButtonsOrder, showDebugInfo = showDebugInfo, longPressAction = Prefs.playerLongPressAction, longPressSpeed = Prefs.playerLongPressSpeed ), LocalVideoPlayerDanmakuMasksData provides VideoPlayerDanmakuMasksData( danmakuMasks = playerViewModel.danmakuMasks, ), LocalVideoPlayerVideoShotData provides VideoPlayerVideoShotData( videoShot = playerViewModel.videoShot, ), ) { Box( modifier = Modifier .onPreviewKeyEvent { keyEvent -> // 检测长按下键,标记跳过对应的 KeyUp 取消 if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.DirectionDown && keyEvent.nativeKeyEvent.isLongPress) { skipNextKeyUpCancel = true } if (keyEvent.type == KeyEventType.KeyUp && autoActionCountdownJob != null) { // 跳过长按下键触发的那次 KeyUp(长按下键释放) if (skipNextKeyUpCancel) { skipNextKeyUpCancel = false return@onPreviewKeyEvent false } // 任何按键都可以取消倒计时 logger.debug { "按下按键: ${keyEvent.key}, 取消播放下一个(或自动退出)" } autoActionCountdownJob?.cancel() autoActionCountdownJob = null autoActionTipVisible = false return@onPreviewKeyEvent keyEvent.key == Key.Back } false } ) { BvPlayer( modifier = modifier .fillMaxSize(), videoPlayer = playerViewModel.videoPlayer!!, playerSeekForwardStep = Prefs.playerSeekForwardStep, playerSeekBackwardStep = Prefs.playerSeekBackwardStep, showBottomProgressBar = Prefs.playerShowBottomProgressBar, useTextureViewFixPortraitVideo = Prefs.portraitVideoFixMode == PortraitVideoFixMode.UseTextureView && playerViewModel.isVerticalVideo && playerViewModel.currentQuality >= Resolution.R4K, onViewerCountTipCanShowChanged = { canShow -> if (canShowViewerCountTip != canShow) { canShowViewerCountTip = canShow } }, viewerCountText = viewerCountText, danmakuView = danmakuView, onToggleRelatedVideos = { state -> playerViewModel.showRelatedVideos = if (playerViewModel.relatedVideos.isNotEmpty() || playerViewModel.preloadedVideoList.isNotEmpty()) state else false }, onSendHeartbeat = playerViewModel::uploadHistory, onClearBackToHistoryData = { playerViewModel.lastPlayed = 0 }, onLoadNextVideo = { immediate -> if (playerViewModel.showRelatedVideos) { logger.info { "Related videos is shown, skip auto action" } return@BvPlayer } if (showCommentPanel) { logger.info { "Comment panel is shown, skip auto action" } return@BvPlayer } // 找出下一个剧集/分P val currentIndex = playerViewModel.availableVideoList.indexOfFirst { when (it) { is VideoListItemData -> it.cid == playerViewModel.currentCid else -> false } } val nextEp = if (currentIndex >= 0 && currentIndex + 1 < playerViewModel.availableVideoList.size) { playerViewModel.availableVideoList .drop(currentIndex + 1) .firstOrNull { it is VideoListItemData } as? VideoListItemData } else null // 找出上一个剧集/分P(逆序模式用) val prevEp = if (currentIndex > 0) { playerViewModel.availableVideoList .take(currentIndex) .lastOrNull { it is VideoListItemData } as? VideoListItemData } else null // 标记当前稿件已播放 PlayedAidsCache.markPlayed(playerViewModel.currentAid) // 找出下一个推荐视频(非充电、非播放过的aid) val candidates = playerViewModel.relatedVideos .filter { related -> !related.isChargingArc && !PlayedAidsCache.hasPlayed(related.avid) } .take(10) val nextRelatedVideo = if (candidates.isNotEmpty()) candidates.random() else null // 找出预加载列表的下一个 val preloaded = playerViewModel.preloadedVideoList val preloadIndex = playerViewModel.resolveLastPreloadedVideoIndex() val nextPreloaded = if (preloadIndex >= 0 && preloadIndex + 1 < preloaded.size) { preloaded[preloadIndex + 1] } else null // 找出预加载列表的上一个(逆序模式用) val prevPreloaded = if (preloadIndex > 0) { preloaded[preloadIndex - 1] } else null // nextVideo 可以是分P/剧集(VideoListItemData) 或推荐卡片(VideoCardData) var nextVideo: Any? = null when (playerViewModel.currentPlayMode) { PlayMode.Custom -> { // 使用设置中的策略顺序 val validOrdinals = NextVideoStrategy.entries.map { it.ordinalValue }.toSet() val strategies = Prefs.playerNextVideoStrategyOrder.split(",").filter { !it.startsWith("-") }.mapNotNull { val id = it.toIntOrNull() ?: return@mapNotNull null; if (id !in validOrdinals) return@mapNotNull null; NextVideoStrategy.fromOrdinal(id) } for (strategy in strategies) { if (strategy == NextVideoStrategy.SingleVideo) { // 单视频模式:不自动播放下一个 break } else if (strategy == NextVideoStrategy.PartAndEpisode) { if (nextEp != null) { nextVideo = nextEp; break } } else if (strategy == NextVideoStrategy.PreloadedVideoList) { if (nextPreloaded != null) { nextVideo = nextPreloaded; break } } else if (strategy == NextVideoStrategy.RelatedVideo) { if (nextRelatedVideo != null) { nextVideo = nextRelatedVideo; break } } else if (strategy == NextVideoStrategy.PartAndEpisodeReverse) { if (prevEp != null) { nextVideo = prevEp; break } } else if (strategy == NextVideoStrategy.PreloadedVideoListReverse) { if (prevPreloaded != null) { nextVideo = prevPreloaded; break } } } } PlayMode.SingleVideo -> { // 单视频模式:不自动播放下一个 logger.info { "PlayMode.SingleVideo: no auto next" } } PlayMode.SingleLoop -> { // BvPlayer.onEnd 已处理循环,这里不应到达 logger.info { "PlayMode.SingleLoop: should not reach onLoadNextVideo" } } PlayMode.ListOrder -> { if (nextPreloaded != null) { playerViewModel.resolveLastPreloadedVideoIndex(nextPreloaded.avid) } nextVideo = nextPreloaded } PlayMode.ListOrderReverse -> { if (prevPreloaded != null) { playerViewModel.resolveLastPreloadedVideoIndex(prevPreloaded.avid) } nextVideo = prevPreloaded } PlayMode.PartAndEpisode -> { nextVideo = nextEp } PlayMode.PartAndEpisodeReverse -> { nextVideo = prevEp } PlayMode.RelatedVideo -> { nextVideo = nextRelatedVideo } } if (nextVideo != null) { autoActionCountdownJob = scope.launch { try { if (!immediate) { autoActionTipText = "即将播放下一个" autoActionTipVisible = true delay(1380) } autoActionTipVisible = false if (autoActionCountdownJob != null) { autoActionCountdownJob = null when (nextVideo) { is VideoListItemData -> { PlayedAidsCache.markPlayed(nextVideo.aid) playerViewModel.title = nextVideo.title playerViewModel.partTitle = nextVideo.partTitle if (nextVideo.seasonId == null && playerViewModel.currentAid != nextVideo.aid) { VideoInfoActivity.actionStart( context = context, aid = nextVideo.aid, cid = nextVideo.cid, fromPlayer = true ) } else { playerViewModel.loadPlayUrl( avid = nextVideo.aid, cid = nextVideo.cid!!, epid = nextVideo.epid, seasonId = nextVideo.seasonId, continuePlayNext = true ) } } is VideoCardData -> { // 推荐视频卡片:跳转到视频详情(再进入播放器) PlayedAidsCache.markPlayed(nextVideo.avid) if (nextVideo.jumpToSeason) { SeasonInfoActivity.actionStart( context = context, epId = nextVideo.epId!!, proxyArea = ProxyArea.checkProxyArea(nextVideo.title) ) } else { VideoInfoActivity.actionStart( context = context, aid = nextVideo.avid, fromPlayer = true ) } } } } } catch (_: Exception) { autoActionTipVisible = false autoActionCountdownJob = null } } } else if (Prefs.playerExitWhenAllIsPlayed) { // 没有下一个:退出 autoActionCountdownJob = scope.launch { try { autoActionTipText = "播放结束,即将退出" autoActionTipVisible = true delay(1380) autoActionTipVisible = false if (autoActionCountdownJob != null) { autoActionCountdownJob = null Prefs.currentPlaySpeed = Prefs.defaultPlaySpeed // 自动退出时也清空缓存 PlayedAidsCache.clear() (context as Activity).finish() } } catch (_: Exception) { autoActionTipVisible = false autoActionCountdownJob = null } } } // 什么都不做 }, onExit = { Prefs.currentPlaySpeed = Prefs.defaultPlaySpeed // 退出时清空播放缓存 PlayedAidsCache.clear() (context as Activity).finish() }, onLoadNewVideo = { videoListItem -> when (videoListItem) { is VideoListItemData -> { // 手动选择新视频时也标记播放 PlayedAidsCache.markPlayed(videoListItem.aid) playerViewModel.title = videoListItem.title playerViewModel.partTitle = videoListItem.partTitle if (videoListItem.seasonId == null && playerViewModel.currentAid != videoListItem.aid) { VideoInfoActivity.actionStart( context = context, aid = videoListItem.aid, cid = videoListItem.cid, fromPlayer = true ) } else { playerViewModel.loadPlayUrl( avid = videoListItem.aid, cid = videoListItem.cid!!, epid = videoListItem.epid, seasonId = videoListItem.seasonId, continuePlayNext = true ) } } } }, onRefreshVideo = { if (playerViewModel.isLive) { // 直播模式:重新获取直播流 URL logger.info { "Reload live stream for room ${playerViewModel.liveRoomId}" } playerViewModel.loadLiveStreamWithQuality( playerViewModel.liveRoomId, playerViewModel.currentLiveQn ) } else { val time = playerViewModel.videoPlayer?.currentPosition ?: 0 logger.info { "Reload video and back to time: ${time.formatHourMinSec()}" } scope.launch { playerViewModel.playQuality() playerViewModel.videoPlayer?.seekTo(time) playerViewModel.danmakuView?.notifySeek(time) playerViewModel.videoPlayer?.start() Toast.makeText( context, "已刷新\nVideo Host: ${playerViewModel.lastVideoHost}\nAudio Host: ${playerViewModel.lastAudioHost}", Toast.LENGTH_SHORT ).show() } } }, onLiveRetry = { playerViewModel.retryLiveStream() }, onShowComment = { showCommentPanel = true }, onShowDescription = { showDescriptionPanel = true }, onResolutionChange = { resolutionCode, afterChange -> scope.launch(Dispatchers.Default) { playerViewModel.playQuality(resolutionCode) afterChange() playerViewModel.currentQuality = resolutionCode } }, onCodecChange = { videoCodec, afterChange -> playerViewModel.currentVideoCodec = videoCodec scope.launch(Dispatchers.Default) { playerViewModel.playQuality( playerViewModel.currentQuality, playerViewModel.currentVideoCodec ) afterChange() } }, onAspectRatioChange = { aspectRatio -> playerViewModel.currentVideoAspectRatio = aspectRatio }, onRotationChange = { rotation -> playerViewModel.currentVideoRotation = rotation }, onPlaySpeedChange = { speed -> Prefs.currentPlaySpeed = speed playerViewModel.currentPlaySpeed = speed }, onAudioChange = { audio, afterChange -> playerViewModel.currentAudio = audio scope.launch(Dispatchers.Default) { playerViewModel.playQuality(audio = audio) afterChange() } }, onLiveQualityChange = { qn -> playerViewModel.changeLiveQuality(qn) }, onLiveCodecChange = { codec -> println("VideoPlayerV3Screen: onLiveCodecChange called with codec=$codec") playerViewModel.changeLiveCodec(codec) }, onDanmakuSwitchChange = { enabledDanmakuTypes -> Prefs.defaultDanmakuTypes = enabledDanmakuTypes playerViewModel.currentDanmakuTypes.swapList(enabledDanmakuTypes) }, onDanmakuSizeChange = { scale -> Prefs.defaultDanmakuScale = scale playerViewModel.currentDanmakuScale = scale }, onDanmakuOpacityChange = { opacity -> Prefs.defaultDanmakuOpacity = opacity playerViewModel.currentDanmakuOpacity = opacity }, onDanmakuAreaChange = { area -> Prefs.defaultDanmakuArea = area playerViewModel.currentDanmakuArea = area }, onDanmakuMaskChange = { mask -> Prefs.defaultDanmakuMask = mask playerViewModel.currentDanmakuMask = mask }, onDanmakuRollingDurationFactorChange = { factor -> Prefs.defaultDanmakuRollingDurationFactor = factor playerViewModel.currentDanmakuRollingDurationFactor = factor }, onDanmakuFilterLevelChange = { filterLevel -> if (playerViewModel.isLive) { Prefs.defaultLiveDanmakuFilterLevel = filterLevel playerViewModel.currentLiveDanmakuFilterLevel = filterLevel } else { Prefs.defaultDanmakuFilterLevel = filterLevel playerViewModel.currentDanmakuFilterLevel = filterLevel } }, onSubtitleChange = { subtitle -> playerViewModel.loadSubtitle(subtitle.id) }, onSubtitleSizeChange = { size -> Prefs.defaultSubtitleFontSize = size playerViewModel.currentSubtitleFontSize = size }, onSubtitleBackgroundOpacityChange = { opacity -> Prefs.defaultSubtitleBackgroundOpacity = opacity playerViewModel.currentSubtitleBackgroundOpacity = opacity }, onSubtitleBottomPadding = { padding -> Prefs.defaultSubtitleBottomPadding = padding playerViewModel.currentSubtitleBottomPadding = padding }, onPlayModeChange = { playMode -> Prefs.defaultPlayMode = playMode playerViewModel.currentPlayMode = playMode }, onDebugInfoChange = { enabled -> Prefs.playerShowDebugInfo = enabled showDebugInfo = enabled }, onOpenUpSpace = { UpInfoActivity.actionStart( context, mid = playerViewModel.upId, name = playerViewModel.upName, face = playerViewModel.upFace ) }, onShowDanmakuChange = { Prefs.showDanmaku = it playerViewModel.showDanmaku = it }, userActionContent = { modifier, focusMap, onFocus, onPauseAutoHide -> if (Prefs.isLogin && !playerViewModel.fromSeason) { // 增加操作:点赞、收藏、投币。通过 focusMap 获取 focusRequester 并在 onFocusChanged 回调时通知 controller val likeFocus = focusMap[UserActionKey.Like] val favFocus = focusMap[UserActionKey.Favorite] val coinFocus = focusMap[UserActionKey.Coin] val toViewFocus = focusMap[UserActionKey.ToView] Row( modifier = modifier .fillMaxWidth() .padding(start = 32.dp, bottom = 4.dp) .offset(y = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { LikeButton( modifier = Modifier .height(26.dp) .onFocusChanged { if (it.isFocused) onFocus(UserActionKey.Like) } .then(likeFocus?.let { Modifier.focusRequester(it) } ?: Modifier), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), colors = ButtonDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), focusedContentColor = MaterialTheme.colorScheme.onSurface ), border = ButtonDefaults.border( border = Border( border = BorderStroke( width = 1.dp, color = Color.Transparent ) ), focusedBorder = Border( border = BorderStroke( width = 1.dp, color = Color.White.copy(alpha = 0.45f) ) ) ), // use shared state isLike = sharedActionState.liked, onToggleLike = { val aid = playerViewModel.currentAid scope.launch { val flow = getStateFlow(aid, Prefs.uid) val current = flow.value if (current.liked) { val success = VideoUserActionManager.delLike(aid, Prefs.uid) if (!success) { "点赞失败".toast(context) } } else { val success = VideoUserActionManager.addLike(aid, Prefs.uid) if (!success) { "取消点赞失败".toast(context) } } } } ) FavoriteButton( modifier = Modifier .height(24.dp) .onFocusChanged { if (it.isFocused) onFocus(UserActionKey.Favorite) } .then(favFocus?.let { Modifier.focusRequester(it) } ?: Modifier), contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp), colors = ButtonDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), focusedContentColor = MaterialTheme.colorScheme.onSurface ), border = ButtonDefaults.border( border = Border( border = BorderStroke( width = 1.dp, color = Color.Transparent ) ), focusedBorder = Border( border = BorderStroke( width = 1.dp, color = Color.White.copy(alpha = 0.45f) ) ) ), dialogContainerColor = Color.Black.copy(alpha = 0.5f), isFavorite = sharedActionState.favorited, favoriteFolderIds = sharedActionState.favoriteFolderIds, onAddToDefaultFavoriteFolder = { scope.launch { val success = VideoUserActionManager.addToDefaultFavoriteFolder(playerViewModel.currentAid, Prefs.uid) if (!success) { "收藏失败!默认收藏夹不存在?".toast(context) } } }, onUpdateFavoriteFolders = { scope.launch { val success = VideoUserActionManager.updateVideoFavoriteFolders(playerViewModel.currentAid, it, Prefs.uid) if (!success) { "收藏失败!此收藏夹收藏数量已达上限(1000)".toast(context) } } }, onDialogVisibilityChanged = onPauseAutoHide ) CoinButton( modifier = Modifier .height(26.dp) .onFocusChanged { if (it.isFocused) onFocus(UserActionKey.Coin) } .then(coinFocus?.let { Modifier.focusRequester(it) } ?: Modifier), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), colors = ButtonDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), focusedContentColor = MaterialTheme.colorScheme.onSurface ), border = ButtonDefaults.border( border = Border( border = BorderStroke( width = 1.dp, color = Color.Transparent ) ), focusedBorder = Border( border = BorderStroke( width = 1.dp, color = Color.White.copy(alpha = 0.45f) ) ) ), isCoin = sharedActionState.coin, onAddCoin = { scope.launch { val success = VideoUserActionManager.addCoin(playerViewModel.currentAid, Prefs.uid) withContext(Dispatchers.Main) { if (!success) { "投币失败".toast(context) } } } } ) ToViewButton( modifier = Modifier .height(26.dp) .onFocusChanged { if (it.isFocused) onFocus(UserActionKey.ToView) } .then(toViewFocus?.let { Modifier.focusRequester(it) } ?: Modifier), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), colors = ButtonDefaults.colors( containerColor = Color.Transparent, focusedContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), focusedContentColor = MaterialTheme.colorScheme.onSurface ), border = ButtonDefaults.border( border = Border( border = BorderStroke( width = 1.dp, color = Color.Transparent ) ), focusedBorder = Border( border = BorderStroke( width = 1.dp, color = Color.White.copy(alpha = 0.45f) ) ) ), onAddToView = { scope.launch { val success = VideoUserActionManager.addToView(playerViewModel.currentAid, Prefs.uid) if (success) { "已添加到稍后再看".toast(context) } else { "添加到稍后再看失败".toast(context) } } } ) } } } ) // 显示跳过提示 if (autoActionTipVisible) { SkipTip( modifier = Modifier.padding(bottom = 22.dp), show = true, text = autoActionTipText, align = Alignment.BottomEnd ) } // 推荐视频 / 视频列表 AnimatedVisibility( modifier = Modifier .align(Alignment.BottomStart) .fillMaxWidth(), visible = playerViewModel.showRelatedVideos && !playerViewModel.isLive && !playerViewModel.fromSeason, enter = expandVertically(), exit = shrinkVertically(), label = "RelatedVideosForPlayer" ) { TabbedVideosPanel( relatedVideos = playerViewModel.relatedVideos, preloadedVideos = playerViewModel.preloadedVideoList, currentAid = playerViewModel.currentAid, focusRequester = relatedVideosFocusRequester, onOpenSeasonInfo = { videoData, fromUGCList -> if (fromUGCList) { playerViewModel.resolveLastPreloadedVideoIndex(videoData.avid) } SeasonInfoActivity.actionStart( context = context, epId = videoData.epId!!, proxyArea = ProxyArea.checkProxyArea(videoData.title) ) }, onOpenVideoInfo = { videoData, fromUGCList -> if (fromUGCList) { playerViewModel.resolveLastPreloadedVideoIndex(videoData.avid) } VideoInfoActivity.actionStart( context = context, aid = videoData.avid, fromPlayer = true ) } ) } // 在线观看人数 Tip OnlineViewerCountTip( show = showOnlineViewerCountTip && canShowViewerCountTip && !playerViewModel.showRelatedVideos, count = onlineViewerCount ) // 评论面板 if (playerViewModel.currentAid > 0) { CommentPanel( show = showCommentPanel, oid = playerViewModel.currentAid, onHide = { showCommentPanel = false } ) } // 简介面板 DescriptionPanel( show = showDescriptionPanel, description = playerViewModel.videoDescription, tags = playerViewModel.videoTags, onHide = { showDescriptionPanel = false }, onClickTag = { tag -> TagActivity.actionStart( context = context, tagId = tag.id, tagName = tag.name ) } ) // 直播人气 Tip(左下角常驻) LiveViewerCountTip( show = showLiveViewerCountTip && canShowViewerCountTip && playerViewModel.livePopularityText.isNotEmpty(), popularityText = playerViewModel.livePopularityText, onlineCount = playerViewModel.liveOnlineCount ) // 风控 Geetest 验证弹窗(TV 遥控器十字光标 + WebView) if (playerViewModel.showGeetestDialog) { GeetestTvVerifyDialog( gt = playerViewModel.geetestGt, challenge = playerViewModel.geetestChallenge, onResult = { result -> playerViewModel.onGeetestResult( challenge = result.challenge, validate = result.validate, seccode = result.seccode, ) }, onDismiss = { playerViewModel.onGeetestCancelled() }, ) } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/login/AppQRLoginContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.login import android.app.Activity import android.view.KeyEvent import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.focusable 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.size import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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.key.key import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.login.QrLoginState import dev.aaa1115910.bv.R import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.login.AppQrLoginViewModel import org.koin.androidx.compose.koinViewModel @Composable fun AppQRLoginContent( modifier: Modifier = Modifier, appQrLoginViewModel: AppQrLoginViewModel = koinViewModel() ) { val context = LocalContext.current LaunchedEffect(Unit) { appQrLoginViewModel.requestQRCode() } LaunchedEffect(appQrLoginViewModel.state) { if (appQrLoginViewModel.state == QrLoginState.Success) { R.string.login_success.toast(context) (context as Activity).finish() } } DisposableEffect(Unit) { onDispose { appQrLoginViewModel.cancelCheckLoginResultTimer() } } Surface( modifier = Modifier.fillMaxSize() ) { Box( modifier = modifier .focusable() .fillMaxSize() .onKeyEvent { if (it.key.nativeKeyCode == KeyEvent.KEYCODE_DPAD_CENTER) { if (listOf(QrLoginState.Expired, QrLoginState.Error) .contains(appQrLoginViewModel.state) ) { appQrLoginViewModel.requestQRCode() } return@onKeyEvent true } false }, contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(36.dp) ) { AnimatedVisibility( visible = listOf(QrLoginState.WaitingForScan, QrLoginState.WaitingForConfirm) .contains(appQrLoginViewModel.state) ) { Box( modifier = Modifier .size(240.dp) .clip(MaterialTheme.shapes.large) .background(Color.White), contentAlignment = Alignment.Center, ) { Image( modifier = Modifier.size(200.dp), bitmap = appQrLoginViewModel.qrImage, contentDescription = null ) } } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = when (appQrLoginViewModel.state) { QrLoginState.Ready, QrLoginState.RequestingQRCode -> stringResource(R.string.login_requesting) QrLoginState.WaitingForScan -> stringResource(R.string.login_wait_for_scan) QrLoginState.WaitingForConfirm -> stringResource(R.string.login_wait_for_confirm) QrLoginState.Expired -> stringResource(R.string.login_expired) QrLoginState.Success -> stringResource(R.string.login_success) QrLoginState.Error, QrLoginState.Unknown -> stringResource(R.string.login_error) }, style = MaterialTheme.typography.displaySmall, ) AnimatedVisibility( visible = listOf(QrLoginState.Expired, QrLoginState.Error) .contains(appQrLoginViewModel.state) ) { Text( text = stringResource(R.string.login_retry), style = MaterialTheme.typography.displaySmall, fontSize = 26.sp ) } } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/login/LoginScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.login import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable fun LoginScreen( modifier: Modifier = Modifier ) { /*when (Prefs.apiType) { ApiType.Http -> { WebQRLoginContent(modifier) } ApiType.GRPC -> { SmsLoginContent(modifier) } }*/ AppQRLoginContent(modifier) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/login/SmsLoginContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.login 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.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.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.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.geetest.sdk.GT3ConfigBean import com.geetest.sdk.GT3ErrorBean import com.geetest.sdk.GT3GeetestUtils import com.geetest.sdk.GT3Listener import dev.aaa1115910.biliapi.repositories.SendSmsState import dev.aaa1115910.bv.R import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.login.GeetestResult import dev.aaa1115910.bv.viewmodel.login.SmsLoginViewModel 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 @Composable fun SmsLoginContent( modifier: Modifier = Modifier, smsLoginViewModel: SmsLoginViewModel = koinViewModel() ) { val context = LocalContext.current val logger = KotlinLogging.logger { } val scope = rememberCoroutineScope() val keyboardController = LocalSoftwareKeyboardController.current var gt3GeetestUtils: GT3GeetestUtils? by remember { mutableStateOf(null) } val gt3ConfigBean by remember { mutableStateOf(GT3ConfigBean()) } var phoneNumberText by remember { mutableStateOf("") } var codeText by remember { mutableStateOf("") } 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 = { keyboardController?.hide() scope.launch(Dispatchers.IO) { runCatching { smsLoginViewModel.sendSms(phoneNumberText.toLong()) { challenge: String, gt: String -> scope.launch(Dispatchers.Main) { setConfig(challenge, gt) } } } } } val loginWithSms = { 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(codeText.toInt()) { (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(phoneNumberText.toLong()) { _, _ -> } } }.onFailure { gt3GeetestUtils?.showFailedDialog() } } } } gt3GeetestUtils!!.init(gt3ConfigBean) onDispose { gt3GeetestUtils?.destory() } } Box( modifier = modifier .fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = phoneNumberText, onValueChange = { phoneNumberText = it // Clear captcha data when phone number changed smsLoginViewModel.clearCaptchaData() }, label = { Text(text = stringResource(R.string.sms_login_phone_number)) }, maxLines = 1, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Phone, imeAction = ImeAction.Send ), keyboardActions = KeyboardActions( onSend = { sendSms() } ) ) Button(onClick = { sendSms() }) { Text(text = stringResource(R.string.sms_login_button_send_sms)) } } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = codeText, onValueChange = { codeText = it }, label = { Text(text = stringResource(R.string.sms_login_code)) }, maxLines = 1, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( onDone = { loginWithSms() } ) ) Button(onClick = { loginWithSms() }) { Text(text = stringResource(R.string.sms_login_button_login)) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/DrawerContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize 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.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.OndemandVideo import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.entity.NavSwitchMode import dev.aaa1115910.bv.tv.util.drawerNavItemsFlow import dev.aaa1115910.bv.tv.util.parseDrawerNavItemsOrder import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.isDpadRight import dev.aaa1115910.bv.util.isKeyDown import dev.aaa1115910.bv.util.onDelayFocusChanged import kotlinx.coroutines.delay // 创建全局的FocusRequester映射表,方便外部使用 val drawerItemFocusRequesters = mutableMapOf().apply { DrawerItem.entries.filter { it != DrawerItem.User && it != DrawerItem.Settings } .forEach { item -> this[item] = FocusRequester() } } // 用于记住每个内容页当前选中的Tab val currentSelectedTabs = mutableStateMapOf() @Composable fun DrawerContent( modifier: Modifier = Modifier, isLogin: Boolean = false, avatar: String = "", username: String = "", navSwitchMode: NavSwitchMode = NavSwitchMode.Auto, onDrawerItemChanged: (DrawerItem) -> Unit = {}, onDrawerItemfocused: (DrawerItem) -> Unit = {}, onOpenSettings: () -> Unit = {}, onShowUserPanel: () -> Unit = {}, onFocusToContent: () -> Unit = {}, onLogin: () -> Unit = {} ) { var selectedItem by remember { mutableStateOf(DrawerItem.Home) } // 添加一个新的状态用于即时跟踪获得焦点的项目 var focusedItem by remember { mutableStateOf(DrawerItem.Home) } var focusOnContent by remember { mutableStateOf(true) } var tabMoved by remember { mutableStateOf(true) } LaunchedEffect(selectedItem) { tabMoved = false delay(200) onDrawerItemChanged(selectedItem) // 别急着向右移动焦点,动画还没结束 delay(200) tabMoved = true } LaunchedEffect(focusedItem) { onDrawerItemfocused(focusedItem) } Column( modifier = modifier .fillMaxSize() .padding(4.dp) .onPreviewKeyEvent { keyEvent -> if (keyEvent.isDpadRight()) { if (keyEvent.isKeyDown()) { if (tabMoved) { focusedItem = selectedItem onFocusToContent() } return@onPreviewKeyEvent true } } false } .onFocusChanged { if (it.hasFocus) { drawerItemFocusRequesters[focusedItem]?.requestFocus() } } .onDelayFocusChanged(delayTime = 0) { focusOnContent = !it.hasFocus }, verticalArrangement = Arrangement.SpaceBetween ) { NavigationRailItem( modifier = Modifier .onFocusChanged { if (it.hasFocus && !focusOnContent) { focusedItem = DrawerItem.User } }, onClick = { if (isLogin) { onShowUserPanel() } else { onLogin() } focusedItem = DrawerItem.User }, selected = focusedItem == DrawerItem.User, colors = NavigationRailItemDefaults.colors( selectedIconColor = Color.Transparent, indicatorColor = Color.Transparent ), icon = { if (isLogin) { AsyncImage( modifier = Modifier .size(52.dp) .ifElse( !focusOnContent && focusedItem == DrawerItem.User, Modifier .border( width = 2.dp, color = MaterialTheme.colorScheme.inverseSurface, shape = CircleShape ) ) .padding(3.dp) // 边框和图片之间的1dp透明区域 .clip(CircleShape), model = avatar, contentDescription = null, contentScale = ContentScale.FillBounds ) } else { Icon( modifier = Modifier .size(46.dp) .ifElse( !focusOnContent && focusedItem == DrawerItem.User, Modifier .border( width = 2.dp, color = MaterialTheme.colorScheme.inverseSurface, shape = CircleShape ) ) .clip(CircleShape), imageVector = DrawerItem.User.displayIcon, contentDescription = null, tint = if (!focusOnContent && focusedItem == DrawerItem.User) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.inverseSurface ) } }, label = { Text( modifier = Modifier.offset(y = (-3).dp), text = if (isLogin) username else DrawerItem.User.displayName, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall ) } ) // 菜单项列表:根据设置排序和过滤 val menuItems by drawerNavItemsFlow.collectAsState( initial = remember { parseDrawerNavItemsOrder(Prefs.drawerNavItemsOrder) } ) // 当选中项不在可见列表中时,自动切换到第一个可见项 LaunchedEffect(menuItems) { if (menuItems.isNotEmpty() && selectedItem !in menuItems && selectedItem != DrawerItem.User && selectedItem != DrawerItem.Settings) { selectedItem = menuItems.first() focusedItem = menuItems.first() } } LazyColumn( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) ) { items(menuItems.size) { index -> val item = menuItems[index] val isSelected = selectedItem == item val isFocused = focusedItem == item && !focusOnContent NavigationRailItem( modifier = Modifier .focusRequester(drawerItemFocusRequesters[item]!!) .onFocusChanged { if (it.hasFocus && !focusOnContent) { focusedItem = item if (navSwitchMode == NavSwitchMode.Auto) { selectedItem = item } } }, onClick = { selectedItem = item focusedItem = item }, selected = isSelected || isFocused, colors = NavigationRailItemDefaults.colors( indicatorColor = when { focusOnContent -> MaterialTheme.colorScheme.surfaceVariant isFocused && isSelected -> MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.75f) isFocused && !isSelected -> MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.3f) isSelected -> MaterialTheme.colorScheme.inverseSurface else -> MaterialTheme.colorScheme.surfaceVariant } ), icon = { Icon( imageVector = item.displayIcon, contentDescription = null, tint = when { isFocused && !isSelected -> MaterialTheme.colorScheme.inverseSurface !focusOnContent && isSelected -> MaterialTheme.colorScheme.surface else -> MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f) } ) }, label = { Text( modifier = Modifier.offset(y = (-4).dp), text = item.displayName, style = MaterialTheme.typography.bodySmall ) } ) } } NavigationRailItem( modifier = Modifier.onFocusChanged { if (it.hasFocus && !focusOnContent) { focusedItem = DrawerItem.Settings } }, onClick = { onOpenSettings() focusedItem = DrawerItem.Settings }, selected = run { val s = selectedItem == DrawerItem.Settings val f = focusedItem == DrawerItem.Settings && !focusOnContent s || f }, colors = run { val s = selectedItem == DrawerItem.Settings val f = focusedItem == DrawerItem.Settings && !focusOnContent NavigationRailItemDefaults.colors( indicatorColor = when { focusOnContent -> MaterialTheme.colorScheme.surfaceVariant f && s -> MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.75f) f && !s -> MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.3f) s -> MaterialTheme.colorScheme.inverseSurface else -> MaterialTheme.colorScheme.surfaceVariant } ) }, icon = { val s = selectedItem == DrawerItem.Settings val f = focusedItem == DrawerItem.Settings && !focusOnContent Icon( imageVector = DrawerItem.Settings.displayIcon, contentDescription = null, tint = when { f && !s -> MaterialTheme.colorScheme.inverseSurface !focusOnContent && s -> MaterialTheme.colorScheme.surface else -> MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.85f) } ) }, label = { Text( modifier = Modifier.offset(y = (-3).dp), text = DrawerItem.Settings.displayName, style = MaterialTheme.typography.bodySmall ) } ) } } enum class DrawerItem( val displayName: String, val displayIcon: ImageVector ) { User(displayName = "点击登录", displayIcon = Icons.Default.AccountCircle), Search(displayName = "搜索", displayIcon = Icons.Default.Search), Home(displayName = "首页", displayIcon = Icons.Default.Home), UGC(displayName = "UGC", displayIcon = Icons.Default.OndemandVideo), PGC(displayName = "PGC", displayIcon = Icons.Default.Movie), Live(displayName = "直播", displayIcon = Icons.Default.Videocam), Settings(displayName = "设置", displayIcon = Icons.Default.Settings), ; } @Preview(device = "id:tv_1080p") @Composable private fun DrawerContentPreview() { BVTheme { Box( modifier = Modifier .fillMaxHeight() .width(180.dp) ) { NavigationRail( modifier = Modifier .align(Alignment.CenterStart) .width(72.dp), containerColor = MaterialTheme.colorScheme.inverseOnSurface ) { DrawerContent() } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/HomeContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.runtime.collectAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.Scaffold 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp import androidx.tv.material3.Text import dev.aaa1115910.bv.tv.component.HomeTopNavItem import dev.aaa1115910.bv.tv.component.TopNav import dev.aaa1115910.bv.tv.screens.main.home.DynamicsScreen import dev.aaa1115910.bv.tv.screens.main.home.PopularScreen import dev.aaa1115910.bv.tv.screens.main.home.RecommendScreen import dev.aaa1115910.bv.tv.screens.user.FavoriteScreen import dev.aaa1115910.bv.tv.screens.user.FollowingSeasonScreen import dev.aaa1115910.bv.tv.screens.user.HistoryScreen import dev.aaa1115910.bv.tv.screens.user.ToViewScreen import dev.aaa1115910.bv.tv.util.homeNavItemsFlow import dev.aaa1115910.bv.tv.util.parseHomeNavItemsOrder import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.viewmodel.UserViewModel import dev.aaa1115910.bv.viewmodel.home.DynamicViewModel import dev.aaa1115910.bv.viewmodel.home.PopularViewModel import dev.aaa1115910.bv.viewmodel.home.RecommendViewModel import dev.aaa1115910.bv.viewmodel.user.FavoriteViewModel import dev.aaa1115910.bv.viewmodel.user.FollowingSeasonViewModel import dev.aaa1115910.bv.viewmodel.user.HistoryViewModel import dev.aaa1115910.bv.viewmodel.user.ToViewViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable fun HomeContent( modifier: Modifier = Modifier, navFocusRequester: FocusRequester, recommendViewModel: RecommendViewModel = koinViewModel(), popularViewModel: PopularViewModel = koinViewModel(), dynamicViewModel: DynamicViewModel = koinViewModel(), favouriteViewModel: FavoriteViewModel = koinViewModel(), followingSeasonViewModel: FollowingSeasonViewModel = koinViewModel(), historyViewModel: HistoryViewModel = koinViewModel(), toViewViewModel: ToViewViewModel = koinViewModel(), userViewModel: UserViewModel = koinViewModel() ) { val scope = rememberCoroutineScope() val logger = KotlinLogging.logger("HomeContent") val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) val recommendState = rememberLazyGridState() val popularState = rememberLazyGridState() val dynamicState = rememberLazyGridState() val favoriteState = rememberLazyGridState() val followingSeasonState = rememberLazyGridState() val historyState = rememberLazyGridState() val toViewState = rememberLazyGridState() var focusOnContent by remember { mutableStateOf(false) } var topNavHasFocus by remember { mutableStateOf(false) } // 用于管理延迟加载的Job var loadJob by remember { mutableStateOf(null) } // 根据设置获取过滤和排序后的导航项列表 val homeNavItems by homeNavItemsFlow.collectAsState( initial = remember { parseHomeNavItemsOrder(Prefs.homeNavItemsOrder) } ) // 处理空列表情况:如果所有导航项都被隐藏,强制显示推荐 val effectiveNavItems = if (homeNavItems.isEmpty()) { listOf(HomeTopNavItem.Recommend) } else { homeNavItems } // 从全局状态获取上次选择的标签位置,如果没有则默认为Recommend var selectedTab by remember { mutableStateOf( currentSelectedTabs[DrawerItem.Home] ?.let { HomeTopNavItem.entries.getOrNull(it) } ?: HomeTopNavItem.entries.getOrElse(Prefs.defaultHomeTab) { HomeTopNavItem.Recommend } ) } fun initData () { scope.launch { when (selectedTab) { HomeTopNavItem.Recommend -> { if (recommendViewModel.recommendVideoList.isEmpty()) { recommendViewModel.loadMore() } } HomeTopNavItem.Popular -> { if (popularViewModel.popularVideoList.isEmpty()) { popularViewModel.loadMore() } } HomeTopNavItem.Dynamics -> { if (dynamicViewModel.dynamicVideoList.isEmpty()) { dynamicViewModel.loadMoreVideo() } } HomeTopNavItem.Favorite -> { // if (favouriteViewModel.favorites.isEmpty() && userViewModel.isLogin) { // favouriteViewModel.updateFoldersInfo() // } } HomeTopNavItem.FollowingSeason -> { // if (followingSeasonViewModel.followingSeasons.isEmpty() && userViewModel.isLogin) { // followingSeasonViewModel.loadMore() // } } HomeTopNavItem.History -> { // if (historyViewModel.histories.isEmpty() && userViewModel.isLogin) { // historyViewModel.update() // } } HomeTopNavItem.ToView -> { // if (toViewViewModel.histories.isEmpty() && userViewModel.isLogin) { // toViewViewModel.update() // } } } } } // 当选中标签变化时,保存到全局状态并处理延迟加载 LaunchedEffect(selectedTab) { currentSelectedTabs[DrawerItem.Home] = selectedTab.ordinal // 取消之前的延迟加载 loadJob?.cancel() // 开始新的延迟加载 loadJob = scope.launch(Dispatchers.IO) { delay(300L) initData() } } val currentListOnTop by remember { derivedStateOf { with( when (selectedTab) { HomeTopNavItem.Recommend -> recommendState HomeTopNavItem.Popular -> popularState HomeTopNavItem.Dynamics -> dynamicState HomeTopNavItem.Favorite -> favoriteState HomeTopNavItem.FollowingSeason -> followingSeasonState HomeTopNavItem.History -> historyState HomeTopNavItem.ToView -> toViewState } ) { firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 } } } LaunchedEffect(Unit) { initData() } //监听登录变化 LaunchedEffect(userViewModel.isLogin) { if (userViewModel.isLogin) { //login userViewModel.updateUserInfo() } else { //logout userViewModel.clearUserInfo() } } BackHandler(focusOnContent || topNavHasFocus) { if (topNavHasFocus) { drawerItemFocusRequesters[DrawerItem.Home]?.requestFocus() return@BackHandler } navFocusRequester.requestFocus(scope) } Scaffold( modifier = modifier, topBar = { TopNav( modifier = Modifier .focusRequester(navFocusRequester) .onFocusChanged { topNavHasFocus = it.hasFocus }, items = effectiveNavItems, initialSelectedItem = selectedTab, navSwitchMode = navSwitchMode, onSelectedChanged = { nav -> loadJob?.cancel() selectedTab = nav as HomeTopNavItem }, onClick = { nav -> loadJob?.cancel() when (nav) { HomeTopNavItem.Recommend -> { logger.fInfo { "clear recommend data" } recommendViewModel.clearData() logger.fInfo { "reload recommend data" } scope.launch(Dispatchers.IO) { recommendViewModel.loadMore() } } HomeTopNavItem.Popular -> { logger.fInfo { "clear popular data" } popularViewModel.clearData() logger.fInfo { "reload popular data" } scope.launch(Dispatchers.IO) { popularViewModel.loadMore() } } HomeTopNavItem.Dynamics -> { logger.fInfo { "clear dynamic data" } dynamicViewModel.clearVideoData() logger.fInfo { "reload dynamic data" } scope.launch(Dispatchers.IO) { dynamicViewModel.loadMoreVideo() } } HomeTopNavItem.Favorite -> { if (userViewModel.isLogin) { favouriteViewModel.clearData() favouriteViewModel.updateFoldersInfo() } } HomeTopNavItem.FollowingSeason -> { if (userViewModel.isLogin) { followingSeasonViewModel.clearData() followingSeasonViewModel.loadMore() } } HomeTopNavItem.History -> { if (userViewModel.isLogin) { historyViewModel.clearData() historyViewModel.update() } } HomeTopNavItem.ToView -> { if (userViewModel.isLogin) { toViewViewModel.clearData() toViewViewModel.update() } } } }, onLeftKeyEvent = { // 顶部栏最左侧按左键时,跳转到左侧导航栏 drawerItemFocusRequesters[DrawerItem.Home]?.requestFocus() } ) } ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() .onFocusChanged { focusOnContent = it.hasFocus } ) { AnimatedContent( targetState = selectedTab, label = "home animated content", transitionSpec = { val coefficient = 10 val initialIndex = effectiveNavItems.indexOf(initialState) .takeIf { it >= 0 } ?: initialState.ordinal val targetIndex = effectiveNavItems.indexOf(targetState) .takeIf { it >= 0 } ?: targetState.ordinal if (targetIndex < initialIndex) { fadeIn() + slideInHorizontally { -it / coefficient } togetherWith fadeOut() + slideOutHorizontally { it / coefficient } } else { fadeIn() + slideInHorizontally { it / coefficient } togetherWith fadeOut() + slideOutHorizontally { -it / coefficient } } } ) { screen -> when (screen) { HomeTopNavItem.Recommend -> RecommendScreen(lazyGridState = recommendState) HomeTopNavItem.Popular -> PopularScreen(lazyGridState = popularState) HomeTopNavItem.Dynamics -> { if (userViewModel.isLogin) { DynamicsScreen(lazyGridState = dynamicState) } else { LoginRequiredScreen() } } HomeTopNavItem.Favorite -> { if (userViewModel.isLogin) { FavoriteScreen(showPageTitle = false) } else { LoginRequiredScreen() } } HomeTopNavItem.FollowingSeason -> { if (userViewModel.isLogin) { FollowingSeasonScreen( showPageTitle = false, topTabFocusRequester = navFocusRequester ) } else { LoginRequiredScreen() } } HomeTopNavItem.History -> { if (userViewModel.isLogin) { HistoryScreen( showPageTitle = false, topTabFocusRequester = navFocusRequester ) } else { LoginRequiredScreen() } } HomeTopNavItem.ToView -> { if (userViewModel.isLogin) { ToViewScreen( showPageTitle = false, topTabFocusRequester = navFocusRequester ) } else { LoginRequiredScreen() } } } } } } } @Composable private fun LoginRequiredScreen() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "请先登录") } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/LiveContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState 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.collectAsState 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.aaa1115910.biliapi.entity.live.LiveAreaItem import dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.tv.component.TopNav import dev.aaa1115910.bv.tv.component.TopNavItem import dev.aaa1115910.bv.tv.component.live.LiveRoomCard import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.getLiveNavItemAreaGroup import dev.aaa1115910.bv.tv.util.isLiveAreaItem import dev.aaa1115910.bv.tv.util.isLiveFollowingItem import dev.aaa1115910.bv.tv.util.isLiveRecommendItem import dev.aaa1115910.bv.tv.util.liveNavItemsOrderFlow import dev.aaa1115910.bv.tv.util.parseLiveNavItemsOrder import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.live.LiveMode import dev.aaa1115910.bv.viewmodel.live.LiveViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import dev.aaa1115910.biliapi.entity.live.LiveAreaGroup import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer // 子分区 TopNavItem private data class SubAreaNavItem(val area: LiveAreaItem) : TopNavItem { override fun getDisplayName(context: Context): String = area.name } @Composable fun LiveContent( modifier: Modifier = Modifier, navFocusRequester: FocusRequester, liveViewModel: LiveViewModel = koinViewModel() ) { val scope = rememberCoroutineScope() val logger = KotlinLogging.logger("LiveContent") val context = LocalContext.current val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) val gridState = rememberLazyGridState() // 使用 MainScreen 传入的 FocusRequester 作为默认入口焦点(从侧边栏按右进入内容区) val parentNavFocusRequester = navFocusRequester val subNavFocusRequester = remember { FocusRequester() } val roomListFocusRestorer = rememberTvLazyListFocusRestorer() var focusOnContent by remember { mutableStateOf(false) } var parentNavHasFocus by remember { mutableStateOf(false) } var subNavHasFocus by remember { mutableStateOf(false) } val currentListOnTop by remember { derivedStateOf { gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 } } // 监听焦点位置,触发分页加载 val focusedIndex = liveViewModel.lastFocusedRoomIndex val totalItems = liveViewModel.roomList.size LaunchedEffect(focusedIndex, totalItems, liveViewModel.loading) { if (totalItems > 0 && (totalItems < 10 || focusedIndex >= totalItems - 8) && liveViewModel.hasMore && !liveViewModel.loading) { logger.info { "Trigger load more, focusedIndex: $focusedIndex, totalItems: $totalItems" } liveViewModel.loadMore() } } LaunchedEffect(liveViewModel.roomList, liveViewModel.loading) { if (liveViewModel.roomList.isEmpty() && liveViewModel.loading) { liveViewModel.lastFocusedRoomIndex = 0 gridState.scrollToItem(0) } } BackHandler(focusOnContent || subNavHasFocus || parentNavHasFocus) { logger.info { "onFocusBackToNav" } if (subNavHasFocus) { parentNavFocusRequester.requestFocus(scope) return@BackHandler } if (parentNavHasFocus) { drawerItemFocusRequesters[DrawerItem.Live]?.requestFocus() return@BackHandler } // 推荐/关注模式没有子分区栏,直接返回主分区栏 if (liveViewModel.currentMode == LiveMode.AREA) { subNavFocusRequester.requestFocus(scope) } else { parentNavFocusRequester.requestFocus(scope) } } Scaffold( modifier = modifier, topBar = { androidx.compose.foundation.layout.Column { // 第一行:推荐 + 关注 + 主分区(根据设置过滤和排序) val liveNavOrderString by liveNavItemsOrderFlow.collectAsState( initial = Prefs.liveNavItemsOrder ) val parentNavItems = remember( liveNavOrderString, liveViewModel.parentAreaGroups.size, liveViewModel.isLoggedIn ) { val items = parseLiveNavItemsOrder( liveNavOrderString, liveViewModel.parentAreaGroups, liveViewModel.isLoggedIn ) // 全部隐藏时强制显示推荐 items.ifEmpty { parseLiveNavItemsOrder("", emptyList(), false) } } val currentParentVisible = remember( parentNavItems, liveViewModel.areaGroupsLoadCompleted, liveViewModel.currentMode, liveViewModel.currentParentGroup?.id ) { liveViewModel.areaGroupsLoadCompleted && parentNavItems.any { it.matchesLiveMode(liveViewModel.currentMode, liveViewModel.currentParentGroup?.id) } } // 首次加载或配置变化时,确保 ViewModel 模式与导航列表一致 var initialSynced by remember { mutableStateOf(false) } LaunchedEffect( parentNavItems, liveViewModel.areaGroupsLoadCompleted, liveViewModel.currentMode, liveViewModel.currentParentGroup?.id ) { if (parentNavItems.isEmpty() || !liveViewModel.areaGroupsLoadCompleted) { return@LaunchedEffect } val shouldSwitch = if (!initialSynced) { initialSynced = true // 首次:排序后的第一项与默认模式不一致时切换 !parentNavItems.first().matchesLiveMode(liveViewModel.currentMode, liveViewModel.currentParentGroup?.id) } else { // 后续:当前选中项被隐藏时切换 !currentParentVisible } if (shouldSwitch) { liveViewModel.lastFocusedRoomIndex = 0 parentNavItems.first().applyToLiveViewModel(liveViewModel) gridState.scrollToItem(0) return@LaunchedEffect } if (currentParentVisible) { liveViewModel.ensureRoomsLoaded() } } val initialSelectedParent = remember(liveViewModel.currentMode, liveViewModel.currentParentGroup, parentNavItems) { parentNavItems.firstOrNull { it.matchesLiveMode(liveViewModel.currentMode, liveViewModel.currentParentGroup?.id) } ?: parentNavItems.firstOrNull() } if (parentNavItems.isNotEmpty()) { TopNav( modifier = Modifier .focusRequester(parentNavFocusRequester) .onFocusChanged { parentNavHasFocus = it.hasFocus }, items = parentNavItems, initialSelectedItem = initialSelectedParent, navSwitchMode = navSwitchMode, onSelectedChanged = { nav -> liveViewModel.lastFocusedRoomIndex = 0 scope.launch { gridState.scrollToItem(0) } nav.applyToLiveViewModel(liveViewModel) }, onClick = { nav -> if (nav.matchesLiveMode(liveViewModel.currentMode, liveViewModel.currentParentGroup?.id)) { liveViewModel.lastFocusedRoomIndex = 0 liveViewModel.refresh() scope.launch { gridState.scrollToItem(0) } } }, onLeftKeyEvent = { drawerItemFocusRequesters[DrawerItem.Live]?.requestFocus() } ) } // 第二行:子分区(仅在分区模式下显示) if (liveViewModel.currentMode == LiveMode.AREA && liveViewModel.subAreaList.isNotEmpty()) { // 监听 currentParentGroup 变化以触发子分区列表更新 val subNavItems = remember(liveViewModel.currentParentGroup, liveViewModel.subAreaList.size) { liveViewModel.subAreaList.map { SubAreaNavItem(it) } } TopNav( modifier = Modifier .focusRequester(subNavFocusRequester) .onFocusChanged { subNavHasFocus = it.hasFocus }, paddingTop = 0.dp, items = subNavItems, useSmallSize = true, initialSelectedItem = subNavItems.firstOrNull { it.area.id == liveViewModel.currentSubArea?.id }, navSwitchMode = navSwitchMode, onSelectedChanged = { nav -> (nav as? SubAreaNavItem)?.let { liveViewModel.lastFocusedRoomIndex = 0 liveViewModel.switchSubArea(it.area) scope.launch { gridState.scrollToItem(0) } } }, onClick = { nav -> (nav as? SubAreaNavItem)?.let { item -> if (item.area.id == liveViewModel.currentSubArea?.id) { liveViewModel.lastFocusedRoomIndex = 0 liveViewModel.refresh() scope.launch { gridState.scrollToItem(0) } } } }, onLeftKeyEvent = { parentNavFocusRequester.requestFocus(scope) } ) } } } ) { innerPadding -> Box( modifier = Modifier .fillMaxSize() .padding(innerPadding) .onFocusChanged { focusOnContent = it.hasFocus } ) { if (liveViewModel.roomList.isEmpty() && liveViewModel.loading) { Row( modifier = Modifier.align(Alignment.Center) ){ LoadingTip() } } else { ProvideListBringIntoViewSpec(topPadding = 12.dp, bottomPadding = 28.dp) { LazyVerticalGrid( modifier = roomListFocusRestorer.containerModifier( Modifier .fillMaxSize() .blockDownFocusExitAtGridEnd( currentIndex = focusedIndex, itemCount = totalItems, columnCount = 4 ) ), state = gridState, columns = GridCells.Fixed(4), contentPadding = PaddingValues(20.dp, 0.dp, 20.dp, 20.dp), verticalArrangement = Arrangement.spacedBy(13.dp), horizontalArrangement = Arrangement.spacedBy(13.dp) ) { itemsIndexed( items = liveViewModel.roomList, key = { index, room -> "$index-room-${room.roomId}" } ) { index, room -> val entryCardModifier = roomListFocusRestorer.firstItemModifier(index) LiveRoomCard( modifier = entryCardModifier, data = room, onClick = { // 保存焦点位置 liveViewModel.lastFocusedRoomIndex = index if (room.liveStatus != 1) { "${room.uname} 未开播".toast(context) return@LiveRoomCard } // 启动播放器 VideoPlayerV3Activity.actionStartLive( context = context, roomId = room.roomId, title = room.title, upId = room.uid, upName = room.uname, upFace = room.face, watchedNum = room.watchedShow?.num ?: (room.online / 10) ) }, onFocus = { liveViewModel.lastFocusedRoomIndex = index logger.debug { "Focus on room ${room.roomId}" } } ) } // 加载中提示 if (liveViewModel.loading) { item { LoadingTip() } } // 没有更多了 if (!liveViewModel.hasMore) { item(span = { GridItemSpan(maxLineSpan) }) { Row( modifier = Modifier.offset(y = (-16).dp), horizontalArrangement = Arrangement.Center ) { Text( text = "没有更多内容了~", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } } } } } } } private fun TopNavItem.matchesLiveMode(mode: LiveMode, parentGroupId: Int?): Boolean = when { isLiveRecommendItem(this) -> mode == LiveMode.RECOMMEND isLiveFollowingItem(this) -> mode == LiveMode.FOLLOWING isLiveAreaItem(this) -> mode == LiveMode.AREA && getLiveNavItemAreaGroup(this)?.id == parentGroupId else -> false } private fun TopNavItem.applyToLiveViewModel(viewModel: LiveViewModel) { when { isLiveRecommendItem(this) -> viewModel.switchToRecommend() isLiveFollowingItem(this) -> viewModel.switchToFollowing() isLiveAreaItem(this) -> getLiveNavItemAreaGroup(this)?.let { viewModel.switchParentArea(it) } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/PgcContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp import dev.aaa1115910.bv.tv.component.PgcTopNavItem import dev.aaa1115910.bv.tv.component.TopNav import dev.aaa1115910.bv.tv.screens.main.pgc.AnimeContent import dev.aaa1115910.bv.tv.screens.main.pgc.DocumentaryContent import dev.aaa1115910.bv.tv.screens.main.pgc.GuoChuangContent import dev.aaa1115910.bv.tv.screens.main.pgc.MovieContent import dev.aaa1115910.bv.tv.screens.main.pgc.TvContent import dev.aaa1115910.bv.tv.screens.main.pgc.VarietyContent import dev.aaa1115910.bv.tv.util.parsePgcTopNavItemsOrder import dev.aaa1115910.bv.tv.util.pgcNavItemsFlow import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.rememberDebouncer import dev.aaa1115910.bv.viewmodel.pgc.PgcAnimeViewModel import dev.aaa1115910.bv.viewmodel.pgc.PgcDocumentaryViewModel import dev.aaa1115910.bv.viewmodel.pgc.PgcGuoChuangViewModel import dev.aaa1115910.bv.viewmodel.pgc.PgcMovieViewModel import dev.aaa1115910.bv.viewmodel.pgc.PgcTvViewModel import dev.aaa1115910.bv.viewmodel.pgc.PgcVarietyViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable fun PgcContent( modifier: Modifier = Modifier, navFocusRequester: FocusRequester, pgcAnimeViewModel: PgcAnimeViewModel = koinViewModel(), pgcGuoChuangViewModel: PgcGuoChuangViewModel = koinViewModel(), pgcMovieViewModel: PgcMovieViewModel = koinViewModel(), pgcDocumentaryViewModel: PgcDocumentaryViewModel = koinViewModel(), pgcTvViewModel: PgcTvViewModel = koinViewModel(), pgcVarietyViewModel: PgcVarietyViewModel = koinViewModel() ) { val scope = rememberCoroutineScope() val logger = KotlinLogging.logger("PgcContent") val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) val animeState = rememberLazyListState() val guoChuangState = rememberLazyListState() val movieState = rememberLazyListState() val documentaryState = rememberLazyListState() val tvState = rememberLazyListState() val varietyState = rememberLazyListState() var focusOnContent by remember { mutableStateOf(false) } var topNavHasFocus by remember { mutableStateOf(false) } // 根据设置获取过滤和排序后的导航项列表 val pgcNavItems by pgcNavItemsFlow.collectAsState( initial = remember { parsePgcTopNavItemsOrder(Prefs.pgcNavItemsOrder) } ) // 处理空列表情况:如果全部被隐藏,强制显示第一个 val effectiveNavItems = if (pgcNavItems.isEmpty()) { listOf(PgcTopNavItem.entries.first()) } else { pgcNavItems } // 使用remember的key参数确保只有在DrawerItem.PGC的tab状态变化时才重新计算 var selectedTab by remember { mutableStateOf( currentSelectedTabs[DrawerItem.PGC] ?.let { PgcTopNavItem.entries.getOrNull(it) } ?: effectiveNavItems.first() ) } // 如果当前选中项被隐藏,则自动切换到第一个可见项 LaunchedEffect(effectiveNavItems) { if (selectedTab !in effectiveNavItems) { selectedTab = effectiveNavItems.first() } } // 当选中标签变化时,保存到全局状态 LaunchedEffect(selectedTab) { currentSelectedTabs[DrawerItem.PGC] = selectedTab.ordinal } val currentListOnTop by remember { derivedStateOf { with( when (selectedTab) { PgcTopNavItem.Anime -> animeState PgcTopNavItem.GuoChuang -> guoChuangState PgcTopNavItem.Movie -> movieState PgcTopNavItem.Documentary -> documentaryState PgcTopNavItem.Tv -> tvState PgcTopNavItem.Variety -> varietyState } ) { firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 } } } //启动时加载当前选中tab的数据 LaunchedEffect(selectedTab) { when (selectedTab) { PgcTopNavItem.Anime -> { if (pgcAnimeViewModel.feedItems.isEmpty()) { logger.fInfo { "加载动画数据" } pgcAnimeViewModel.init() } } PgcTopNavItem.GuoChuang -> { if (pgcGuoChuangViewModel.feedItems.isEmpty()) { logger.fInfo { "加载国创数据" } pgcGuoChuangViewModel.init() } } PgcTopNavItem.Movie -> { if (pgcMovieViewModel.feedItems.isEmpty()) { logger.fInfo { "加载电影数据" } pgcMovieViewModel.init() } } PgcTopNavItem.Documentary -> { if (pgcDocumentaryViewModel.feedItems.isEmpty()) { logger.fInfo { "加载纪录片数据" } pgcDocumentaryViewModel.init() } } PgcTopNavItem.Tv -> { if (pgcTvViewModel.feedItems.isEmpty()) { logger.fInfo { "加载电视剧数据" } pgcTvViewModel.init() } } PgcTopNavItem.Variety -> { if (pgcVarietyViewModel.feedItems.isEmpty()) { logger.fInfo { "加载综艺数据" } pgcVarietyViewModel.init() } } } } BackHandler(focusOnContent || topNavHasFocus) { logger.fInfo { "onFocusBackToNav" } // 如果顶部导航有焦点,则返回到左边栏的PGC位置 if (topNavHasFocus) { drawerItemFocusRequesters[DrawerItem.PGC]?.requestFocus() return@BackHandler } navFocusRequester.requestFocus(scope) } Scaffold( modifier = modifier, topBar = { TopNav( modifier = Modifier .focusRequester(navFocusRequester) .onFocusChanged { topNavHasFocus = it.hasFocus }, items = effectiveNavItems, initialSelectedItem = selectedTab, navSwitchMode = navSwitchMode, onSelectedChanged = { nav -> selectedTab = nav as PgcTopNavItem }, onClick = { nav -> when (nav) { PgcTopNavItem.Anime -> pgcAnimeViewModel.reloadAll() PgcTopNavItem.GuoChuang -> pgcGuoChuangViewModel.reloadAll() PgcTopNavItem.Movie -> pgcMovieViewModel.reloadAll() PgcTopNavItem.Documentary -> pgcDocumentaryViewModel.reloadAll() PgcTopNavItem.Tv -> pgcTvViewModel.reloadAll() PgcTopNavItem.Variety -> pgcVarietyViewModel.reloadAll() } }, onLeftKeyEvent = { // 顶部栏最左侧按左键时,跳转到左侧导航栏 drawerItemFocusRequesters[DrawerItem.PGC]?.requestFocus() } ) } ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() .onFocusChanged { focusOnContent = it.hasFocus } ) { AnimatedContent( targetState = selectedTab, label = "pgc animated content", transitionSpec = { val coefficient = 10 val initialIndex = effectiveNavItems.indexOf(initialState) .takeIf { it >= 0 } ?: initialState.ordinal val targetIndex = effectiveNavItems.indexOf(targetState) .takeIf { it >= 0 } ?: targetState.ordinal if (targetIndex < initialIndex) { fadeIn() + slideInHorizontally { -it / coefficient } togetherWith fadeOut() + slideOutHorizontally { it / coefficient } } else { fadeIn() + slideInHorizontally { it / coefficient } togetherWith fadeOut() + slideOutHorizontally { -it / coefficient } } } ) { screen -> when (screen) { PgcTopNavItem.Anime -> AnimeContent(lazyListState = animeState) PgcTopNavItem.GuoChuang -> GuoChuangContent(lazyListState = guoChuangState) PgcTopNavItem.Movie -> MovieContent(lazyListState = movieState) PgcTopNavItem.Documentary -> DocumentaryContent(lazyListState = documentaryState) PgcTopNavItem.Tv -> TvContent(lazyListState = tvState) PgcTopNavItem.Variety -> VarietyContent(lazyListState = varietyState) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/UgcContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import dev.aaa1115910.bv.tv.component.TopNav import dev.aaa1115910.bv.tv.component.UgcTopNavItem import dev.aaa1115910.bv.tv.screens.main.ugc.CreateUgcContent import dev.aaa1115910.bv.tv.util.parseUgcTopNavItemsOrder import dev.aaa1115910.bv.tv.util.ugcNavItemsFlow import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.viewmodel.ugc.UgcAiViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcAnimalViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcCarViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcCinephileViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcDanceViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcDougaViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcEmotionViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcEntViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcFashionViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcFoodViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcGameViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcGymViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcHandmakeViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcHealthViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcHomeViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcInformationViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcKichikuViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcKnowledgeViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcLifeExperienceViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcLifeJoyViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcMusicViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcMysticismViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcOutdoorsViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcPaintingViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcParentingViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcRuralViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcShortplayViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcSportsViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcTechViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcTravelViewModel import dev.aaa1115910.bv.viewmodel.ugc.UgcVlogViewModel import io.github.oshai.kotlinlogging.KotlinLogging import org.koin.androidx.compose.koinViewModel @Composable fun UgcContent( modifier: Modifier = Modifier, navFocusRequester: FocusRequester, ugcDougaViewModel: UgcDougaViewModel = koinViewModel(), ugcGameViewModel: UgcGameViewModel = koinViewModel(), ugcKichikuViewModel: UgcKichikuViewModel = koinViewModel(), ugcMusicViewModel: UgcMusicViewModel = koinViewModel(), ugcDanceViewModel: UgcDanceViewModel = koinViewModel(), ugcCinephileViewModel: UgcCinephileViewModel = koinViewModel(), ugcEntViewModel: UgcEntViewModel = koinViewModel(), ugcKnowledgeViewModel: UgcKnowledgeViewModel = koinViewModel(), ugcTechViewModel: UgcTechViewModel = koinViewModel(), ugcInformationViewModel: UgcInformationViewModel = koinViewModel(), ugcFoodViewModel: UgcFoodViewModel = koinViewModel(), ugcShortplayViewModel: UgcShortplayViewModel = koinViewModel(), ugcCarViewModel: UgcCarViewModel = koinViewModel(), ugcFashionViewModel: UgcFashionViewModel = koinViewModel(), ugcSportsViewModel: UgcSportsViewModel = koinViewModel(), ugcAnimalViewModel: UgcAnimalViewModel = koinViewModel(), ugcVlogViewModel: UgcVlogViewModel = koinViewModel(), ugcPaintingViewModel: UgcPaintingViewModel = koinViewModel(), ugcAiViewModel: UgcAiViewModel = koinViewModel(), ugcHomeViewModel: UgcHomeViewModel = koinViewModel(), ugcOutdoorsViewModel: UgcOutdoorsViewModel = koinViewModel(), ugcGymViewModel: UgcGymViewModel = koinViewModel(), ugcHandmakeViewModel: UgcHandmakeViewModel = koinViewModel(), ugcTravelViewModel: UgcTravelViewModel = koinViewModel(), ugcRuralViewModel: UgcRuralViewModel = koinViewModel(), ugcParentingViewModel: UgcParentingViewModel = koinViewModel(), ugcHealthViewModel: UgcHealthViewModel = koinViewModel(), ugcEmotionViewModel: UgcEmotionViewModel = koinViewModel(), ugcLifeJoyViewModel: UgcLifeJoyViewModel = koinViewModel(), ugcLifeExperienceViewModel: UgcLifeExperienceViewModel = koinViewModel(), ugcMysticismViewModel: UgcMysticismViewModel = koinViewModel(), ) { val scope = rememberCoroutineScope() val logger = KotlinLogging.logger("UgcContent") val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) // 为当前选中的tab创建LazyGridState val currentLazyGridState = rememberLazyGridState() var focusOnContent by remember { mutableStateOf(false) } var topNavHasFocus by remember { mutableStateOf(false) } // 根据设置获取过滤和排序后的导航项列表 val ugcNavItems by ugcNavItemsFlow.collectAsState( initial = remember { parseUgcTopNavItemsOrder(Prefs.ugcNavItemsOrder) } ) // 处理空列表情况:如果全部被隐藏,强制显示第一个 val effectiveNavItems = if (ugcNavItems.isEmpty()) { listOf(UgcTopNavItem.entries.first()) } else { ugcNavItems } // 使用remember的key参数确保只有在DrawerItem.UGC的tab状态变化时才重新计算 var selectedTab by remember { mutableStateOf( currentSelectedTabs[DrawerItem.UGC] ?.let { UgcTopNavItem.entries.getOrNull(it) } ?: effectiveNavItems.first() ) } // 如果当前选中项被隐藏,则自动切换到第一个可见项 LaunchedEffect(effectiveNavItems) { if (selectedTab !in effectiveNavItems) { selectedTab = effectiveNavItems.first() } } // 获取所有ViewModels的映射 val viewModelMap = remember { mapOf( UgcTopNavItem.Douga to ugcDougaViewModel, UgcTopNavItem.Game to ugcGameViewModel, UgcTopNavItem.Kichiku to ugcKichikuViewModel, UgcTopNavItem.Music to ugcMusicViewModel, UgcTopNavItem.Dance to ugcDanceViewModel, UgcTopNavItem.Cinephile to ugcCinephileViewModel, UgcTopNavItem.Ent to ugcEntViewModel, UgcTopNavItem.Knowledge to ugcKnowledgeViewModel, UgcTopNavItem.Tech to ugcTechViewModel, UgcTopNavItem.Information to ugcInformationViewModel, UgcTopNavItem.Food to ugcFoodViewModel, UgcTopNavItem.ShortPlay to ugcShortplayViewModel, UgcTopNavItem.Car to ugcCarViewModel, UgcTopNavItem.Fashion to ugcFashionViewModel, UgcTopNavItem.Sports to ugcSportsViewModel, UgcTopNavItem.Animal to ugcAnimalViewModel, UgcTopNavItem.Vlog to ugcVlogViewModel, UgcTopNavItem.Painting to ugcPaintingViewModel, UgcTopNavItem.Ai to ugcAiViewModel, UgcTopNavItem.Home to ugcHomeViewModel, UgcTopNavItem.Outdoors to ugcOutdoorsViewModel, UgcTopNavItem.Gym to ugcGymViewModel, UgcTopNavItem.Handmake to ugcHandmakeViewModel, UgcTopNavItem.Travel to ugcTravelViewModel, UgcTopNavItem.Rural to ugcRuralViewModel, UgcTopNavItem.Parenting to ugcParentingViewModel, UgcTopNavItem.Health to ugcHealthViewModel, UgcTopNavItem.Emotion to ugcEmotionViewModel, UgcTopNavItem.LifeJoy to ugcLifeJoyViewModel, UgcTopNavItem.LifeExperience to ugcLifeExperienceViewModel, UgcTopNavItem.Mysticism to ugcMysticismViewModel ) } // 当选中标签变化时,保存到全局状态并处理懒加载 LaunchedEffect(selectedTab) { currentSelectedTabs[DrawerItem.UGC] = selectedTab.ordinal // 取消所有其他ViewModel的延迟加载 viewModelMap.values.forEach { viewModel -> viewModel.cancelDelayedLoad() } // 为当前选中的ViewModel开始延迟加载 viewModelMap[selectedTab]?.loadDataWithDelay(300L) } BackHandler(focusOnContent || topNavHasFocus) { logger.fInfo { "onFocusBackToNav" } if (topNavHasFocus) { drawerItemFocusRequesters[DrawerItem.UGC]?.requestFocus() return@BackHandler } navFocusRequester.requestFocus(scope) // 滚动到顶部(如果需要的话) // scope.launch(Dispatchers.Main) { // currentLazyGridState.animateScrollToItem(0) // } } Scaffold( modifier = modifier, topBar = { TopNav( modifier = Modifier .focusRequester(navFocusRequester) .onFocusChanged { topNavHasFocus = it.hasFocus }, items = effectiveNavItems, initialSelectedItem = selectedTab, navSwitchMode = navSwitchMode, onSelectedChanged = { nav -> selectedTab = nav as UgcTopNavItem // 取消非selectedTab的所有延迟加载 viewModelMap .filterKeys { it != selectedTab } .values .forEach { it.cancelDelayedLoad() } }, onClick = { nav -> // 点击时立即加载数据 viewModelMap[nav as UgcTopNavItem]?.reloadAll() }, onLeftKeyEvent = { // 顶部栏最左侧按左键时,跳转到左侧导航栏 drawerItemFocusRequesters[DrawerItem.UGC]?.requestFocus() } ) } ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .fillMaxSize() .onFocusChanged { focusOnContent = it.hasFocus } ) { AnimatedContent( targetState = selectedTab, label = "ugc animated content", transitionSpec = { val coefficient = 10 val initialIndex = effectiveNavItems.indexOf(initialState) .takeIf { it >= 0 } ?: initialState.ordinal val targetIndex = effectiveNavItems.indexOf(targetState) .takeIf { it >= 0 } ?: targetState.ordinal if (targetIndex < initialIndex) { fadeIn() + slideInHorizontally { -it / coefficient } togetherWith fadeOut() + slideOutHorizontally { it / coefficient } } else { fadeIn() + slideInHorizontally { it / coefficient } togetherWith fadeOut() + slideOutHorizontally { -it / coefficient } } } ) { screen -> CreateUgcContent( navItem = screen, lazyGridState = currentLazyGridState, ugcViewModel = viewModelMap[screen]!! ) } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/home/DynamicsScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.home import android.content.Intent import android.view.KeyEvent 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.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan 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.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.user.DynamicVideo import dev.aaa1115910.bv.R as SharedR import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.R import dev.aaa1115910.bv.tv.activities.user.FollowActivity import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.viewmodel.home.DynamicViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun DynamicsScreen( modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState(), dynamicViewModel: DynamicViewModel = koinViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val videoInfoRepository: VideoInfoRepository = koinInject() val listFocusRestorer = rememberTvLazyListFocusRestorer() var currentFocusedIndex by remember { mutableIntStateOf(-1) } val shouldLoadMore by remember { derivedStateOf { dynamicViewModel.dynamicVideoList.isNotEmpty() && currentFocusedIndex + 12 > dynamicViewModel.dynamicVideoList.size } } val onClickVideo: (DynamicVideo) -> Unit = { dynamic -> val proxyArea = ProxyArea.checkProxyArea(dynamic.title) val hasSeasonHint = dynamic.seasonId != null || dynamic.epid != null videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll( dynamicViewModel.dynamicVideoList.map { item -> VideoCardData( avid = item.aid, title = item.title, cover = item.cover, upName = item.author, upId = item.authorId, play = item.play, danmaku = item.danmaku, time = item.duration * 1000L, pubTime = item.pubTime ) } ) if (hasSeasonHint) { SeasonInfoActivity.actionStart( context = context, epId = dynamic.epid, seasonId = dynamic.seasonId, proxyArea = proxyArea ) } else { VideoInfoActivity.actionStart( context = context, aid = dynamic.aid, proxyArea = proxyArea ) } } val onLongClickVideo: (DynamicVideo) -> Unit = { dynamic -> UpInfoActivity.actionStart( context, mid = dynamic.authorId, name = dynamic.author, face = dynamic.authorFace ) } //不能直接使用 LaunchedEffect(currentFocusedIndex),会导致整个页面重组 LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { scope.launch(Dispatchers.IO) { dynamicViewModel.loadMoreVideo() } } } if (dynamicViewModel.isLogin) { val padding = dimensionResource(R.dimen.grid_padding) val spacedBy = dimensionResource(R.dimen.grid_spacedBy) Text( modifier = Modifier.fillMaxWidth().offset(x = (-20).dp, y = (-8).dp), text = stringResource(R.string.entry_follow_screen), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), fontSize = 11.sp, textAlign = TextAlign.End ) ProvideListBringIntoViewSpec { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier(modifier.fillMaxSize()) .blockDownFocusExitAtGridEnd( currentIndex = currentFocusedIndex, itemCount = dynamicViewModel.dynamicVideoList.size, columnCount = 4 ) .onFocusChanged{ if (!it.isFocused) { currentFocusedIndex = -1 } } .onPreviewKeyEvent { keyEvent -> if ( keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_MENU ) { context.startActivity(Intent(context, FollowActivity::class.java)) return@onPreviewKeyEvent true } false }, columns = GridCells.Fixed(4), state = lazyGridState, contentPadding = PaddingValues(padding), verticalArrangement = Arrangement.spacedBy(spacedBy), horizontalArrangement = Arrangement.spacedBy(spacedBy) ) { itemsIndexed( items = dynamicViewModel.dynamicVideoList, key = { index, item -> "$index-av-${item.aid}" } ) { index, item -> SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index), data = remember(item.aid) { VideoCardData( avid = item.aid, title = item.title, cover = item.cover, play = item.play, danmaku = item.danmaku, upName = item.author, time = item.duration * 1000L, pubTime = item.pubTime, isChargingArc = item.isChargingArc, badgeText = item.chargingArcBadge ) }, onClick = { onClickVideo(item) }, onLongClick = {onLongClickVideo(item) }, onFocus = { currentFocusedIndex = index } ) } if ( dynamicViewModel.dynamicVideoList.isEmpty() && !dynamicViewModel.loadingVideo && !dynamicViewModel.videoHasMore ) { item(span = { GridItemSpan(maxLineSpan) }) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = stringResource(SharedR.string.no_data), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } if (dynamicViewModel.loadingVideo) { item(span = { GridItemSpan(maxLineSpan) }) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { LoadingTip() } } } if (!dynamicViewModel.videoHasMore && dynamicViewModel.dynamicVideoList.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { Text( text = "没有更多了捏", color = Color.White ) } } } } } else { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = "请先登录") } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/home/PopularScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.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.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.dimensionResource import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.ugc.UgcItem import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.tv.R import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.viewmodel.home.PopularViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun PopularScreen( modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState(), popularViewModel: PopularViewModel = koinViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val videoInfoRepository: VideoInfoRepository = koinInject() val listFocusRestorer = rememberTvLazyListFocusRestorer() var currentFocusedIndex by remember { mutableIntStateOf(0) } val shouldLoadMore by remember { derivedStateOf { popularViewModel.popularVideoList.isNotEmpty() && currentFocusedIndex + 12 > popularViewModel.popularVideoList.size } } val onClickVideo: (UgcItem) -> Unit = { ugcItem -> videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll( popularViewModel.popularVideoList.map { item -> VideoCardData( avid = item.aid, title = item.title, cover = item.cover, upName = item.author, upId = item.authorId, play = if (item.play == -1L) null else item.play, danmaku = if (item.danmaku == -1) null else item.danmaku, time = item.duration * 1000L, pubTime = item.pubTime ) } ) VideoInfoActivity.actionStart(context, ugcItem.aid) } val onLongClickVideo: (UgcItem) -> Unit = { ugcItem -> UpInfoActivity.actionStart( context, mid = ugcItem.authorId, name = ugcItem.author, face = ugcItem.authorFace ) } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { scope.launch(Dispatchers.IO) { popularViewModel.loadMore() } } } val padding = dimensionResource(R.dimen.grid_padding) val spacedBy = dimensionResource(R.dimen.grid_spacedBy) ProvideListBringIntoViewSpec { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier( modifier .fillMaxSize() .blockDownFocusExitAtGridEnd( currentIndex = currentFocusedIndex, itemCount = popularViewModel.popularVideoList.size, columnCount = 4 ) ), columns = GridCells.Fixed(4), state = lazyGridState, contentPadding = PaddingValues(padding), verticalArrangement = Arrangement.spacedBy(spacedBy), horizontalArrangement = Arrangement.spacedBy(spacedBy) ) { itemsIndexed( items = popularViewModel.popularVideoList, key = { index, item -> "$index-av-${item.aid}" } ) { index, item -> SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index), data = remember(item.aid) { VideoCardData( avid = item.aid, title = item.title, cover = item.cover, play = item.play, danmaku = item.danmaku, upName = item.author, time = item.duration * 1000L, pubTime = item.pubTime ) }, onClick = { onClickVideo(item) }, onLongClick = {onLongClickVideo(item) }, onFocus = { currentFocusedIndex = index } ) } if (popularViewModel.loading) { item(span = { GridItemSpan(maxLineSpan) }) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { LoadingTip() } } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/home/RecommendScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.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.GridItemSpan 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.dimensionResource import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.ugc.UgcItem import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.tv.R import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.viewmodel.home.RecommendViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun RecommendScreen( modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState(), recommendViewModel: RecommendViewModel = koinViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val videoInfoRepository: VideoInfoRepository = koinInject() val listFocusRestorer = rememberTvLazyListFocusRestorer() var currentFocusedIndex by remember { mutableIntStateOf(0) } val shouldLoadMore by remember { derivedStateOf { recommendViewModel.recommendVideoList.isNotEmpty() && currentFocusedIndex + 12 > recommendViewModel.recommendVideoList.size } } val onClickVideo: (UgcItem) -> Unit = { ugcItem -> videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll( recommendViewModel.recommendVideoList.map { item -> VideoCardData( avid = item.aid, title = item.title, cover = item.cover, upName = item.author, upId = item.authorId, play = if (item.play == -1L) null else item.play, danmaku = if (item.danmaku == -1) null else item.danmaku, time = item.duration * 1000L, pubTime = item.pubTime ) } ) VideoInfoActivity.actionStart(context, ugcItem.aid) } val onLongClickVideo: (UgcItem) -> Unit = { ugcItem -> UpInfoActivity.actionStart( context, mid = ugcItem.authorId, name = ugcItem.author, face = ugcItem.authorFace ) } //不能直接使用 LaunchedEffect(currentFocusedIndex),会导致整个页面重组 LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { scope.launch(Dispatchers.IO) { recommendViewModel.loadMore() } } } val padding = dimensionResource(R.dimen.grid_padding) val spacedBy = dimensionResource(R.dimen.grid_spacedBy) ProvideListBringIntoViewSpec { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier( modifier .fillMaxSize() .blockDownFocusExitAtGridEnd( currentIndex = currentFocusedIndex, itemCount = recommendViewModel.recommendVideoList.size, columnCount = 4 ) ), columns = GridCells.Fixed(4), state = lazyGridState, contentPadding = PaddingValues(padding), verticalArrangement = Arrangement.spacedBy(spacedBy), horizontalArrangement = Arrangement.spacedBy(spacedBy) ) { itemsIndexed( items = recommendViewModel.recommendVideoList, key = { index, item -> "$index-av-${item.aid}" } ) { index, item -> SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index), data = remember(item.aid) { VideoCardData( avid = item.aid, title = item.title, cover = item.cover, play = with(item.play) { if (this == -1L) null else this }, danmaku = with(item.danmaku) { if (this == -1) null else this }, upName = item.author, time = item.duration * 1000L, pubTime = item.pubTime ) }, onClick = { onClickVideo(item) }, onLongClick = {onLongClickVideo(item) }, onFocus = { currentFocusedIndex = index } ) } if (recommendViewModel.loading) { item(span = { GridItemSpan(maxLineSpan) }) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { LoadingTip() } } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/AnimeContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import android.content.Intent import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.Alarm import androidx.compose.material.icons.rounded.Favorite import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.pgc.PgcIndexActivity import dev.aaa1115910.bv.tv.activities.pgc.anime.AnimeTimelineActivity import dev.aaa1115910.bv.tv.activities.user.FollowingSeasonActivity import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.pgc.PgcAnimeViewModel import org.koin.androidx.compose.koinViewModel @Composable fun AnimeContent( modifier: Modifier = Modifier, lazyListState: LazyListState, pgcViewModel: PgcAnimeViewModel = koinViewModel() ) { val context = LocalContext.current val onOpenTimeline: () -> Unit = { context.startActivity(Intent(context, AnimeTimelineActivity::class.java)) } val onOpenFollowing: () -> Unit = { context.startActivity(Intent(context, FollowingSeasonActivity::class.java)) } val onOpenIndex: () -> Unit = { PgcIndexActivity.actionStart(context = context, pgcType = PgcType.Anime) } val onOpenGamerAni: () -> Unit = { val packageManager = context.packageManager val gamerAniPackageName = "tw.com.gamer.android.animad" packageManager.getLeanbackLaunchIntentForPackage(gamerAniPackageName)?.let { context.startActivity(it) } ?: run { R.string.anime_home_button_gamer_ani_launch_failed.toast(context) } } PgcScaffold( lazyListState = lazyListState, pgcViewModel = pgcViewModel, pgcType = PgcType.Anime, featureButtons = { AnimeFeatureButtons( modifier = Modifier.padding(vertical = 24.dp), onOpenTimeline = onOpenTimeline, onOpenFollowing = onOpenFollowing, onOpenIndex = onOpenIndex, onOpenGamerAni = onOpenGamerAni ) } ) } @Composable private fun AnimeFeatureButtons( modifier: Modifier = Modifier, onOpenTimeline: () -> Unit, onOpenFollowing: () -> Unit, onOpenIndex: () -> Unit, onOpenGamerAni: () -> Unit = {} ) { val buttons = listOf( Triple( stringResource(R.string.anime_home_button_timeline), Icons.Rounded.Alarm, onOpenTimeline ), Triple( stringResource(R.string.anime_home_button_following), Icons.Rounded.Favorite, onOpenFollowing ), Triple( stringResource(R.string.anime_home_button_index), Icons.AutoMirrored.Rounded.List, onOpenIndex ), Triple( stringResource(R.string.anime_home_button_gamer_ani), painterResource(R.drawable.ic_gamer_ani), onOpenGamerAni ) ) PgcFeatureButtons( modifier = modifier, buttons = buttons ) } @Preview(device = "id:tv_1080p") @Composable private fun AnimeFeatureButtonsPreview() { BVTheme { AnimeFeatureButtons( modifier = Modifier, onOpenTimeline = {}, onOpenFollowing = {}, onOpenIndex = {}, onOpenGamerAni = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/DocumentaryContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.runtime.Composable 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.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.pgc.PgcIndexActivity import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.viewmodel.pgc.PgcDocumentaryViewModel import org.koin.androidx.compose.koinViewModel @Composable fun DocumentaryContent( modifier: Modifier = Modifier, lazyListState: LazyListState, pgcViewModel: PgcDocumentaryViewModel = koinViewModel() ) { val context = LocalContext.current val onOpenIndex: () -> Unit = { PgcIndexActivity.actionStart(context = context, pgcType = PgcType.Documentary) } PgcScaffold( lazyListState = lazyListState, pgcViewModel = pgcViewModel, pgcType = PgcType.Documentary, featureButtons = { DocumentaryFeatureButtons( modifier = Modifier.padding(vertical = 24.dp), onOpenIndex = onOpenIndex ) } ) } @Composable private fun DocumentaryFeatureButtons( modifier: Modifier = Modifier, onOpenIndex: () -> Unit ) { val buttons = listOf( Triple( stringResource(R.string.anime_home_button_index), Icons.AutoMirrored.Rounded.List, onOpenIndex ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ) ) PgcFeatureButtons( modifier = modifier, buttons = buttons ) } @Preview(device = "id:tv_1080p") @Composable private fun DocumentaryFeatureButtonsPreview() { BVTheme { DocumentaryFeatureButtons( modifier = Modifier, onOpenIndex = {}, ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/GuoChuangContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.runtime.Composable 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.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.pgc.PgcIndexActivity import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.viewmodel.pgc.PgcGuoChuangViewModel import org.koin.androidx.compose.koinViewModel @Composable fun GuoChuangContent( modifier: Modifier = Modifier, lazyListState: LazyListState, pgcViewModel: PgcGuoChuangViewModel = koinViewModel() ) { val context = LocalContext.current val onOpenIndex: () -> Unit = { PgcIndexActivity.actionStart(context = context, pgcType = PgcType.GuoChuang) } PgcScaffold( lazyListState = lazyListState, pgcViewModel = pgcViewModel, pgcType = PgcType.GuoChuang, featureButtons = { GuoChuangFeatureButtons( modifier = Modifier.padding(vertical = 24.dp), onOpenIndex = onOpenIndex ) } ) } @Composable private fun GuoChuangFeatureButtons( modifier: Modifier = Modifier, onOpenIndex: () -> Unit ) { val buttons = listOf( Triple( stringResource(R.string.anime_home_button_index), Icons.AutoMirrored.Rounded.List, onOpenIndex ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ) ) PgcFeatureButtons( modifier = modifier, buttons = buttons ) } @Preview(device = "id:tv_1080p") @Composable private fun GuoChuangFeatureButtonsPreview() { BVTheme { GuoChuangFeatureButtons( modifier = Modifier, onOpenIndex = {}, ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/MovieContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.runtime.Composable 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.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.pgc.PgcIndexActivity import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.viewmodel.pgc.PgcMovieViewModel import org.koin.androidx.compose.koinViewModel @Composable fun MovieContent( modifier: Modifier = Modifier, lazyListState: LazyListState, pgcViewModel: PgcMovieViewModel = koinViewModel() ) { val context = LocalContext.current val onOpenIndex: () -> Unit = { PgcIndexActivity.actionStart(context = context, pgcType = PgcType.Movie) } PgcScaffold( lazyListState = lazyListState, pgcViewModel = pgcViewModel, pgcType = PgcType.Movie, featureButtons = { MovieFeatureButtons( modifier = Modifier.padding(vertical = 24.dp), onOpenIndex = onOpenIndex ) } ) } @Composable private fun MovieFeatureButtons( modifier: Modifier = Modifier, onOpenIndex: () -> Unit ) { val buttons = listOf( Triple( stringResource(R.string.anime_home_button_index), Icons.AutoMirrored.Rounded.List, onOpenIndex ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ) ) PgcFeatureButtons( modifier = modifier, buttons = buttons ) } @Preview(device = "id:tv_1080p") @Composable private fun MovieFeatureButtonsPreview() { BVTheme { MovieFeatureButtons( modifier = Modifier, onOpenIndex = {}, ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/PgcCommon.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import android.view.KeyEvent import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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.key.Key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.pgc.PgcFeedData import dev.aaa1115910.biliapi.entity.pgc.PgcItem import dev.aaa1115910.biliapi.entity.pgc.PgcType import dev.aaa1115910.biliapi.http.SeasonIndexType import dev.aaa1115910.bv.BVApp import dev.aaa1115910.bv.tv.component.PgcCarousel import dev.aaa1115910.bv.tv.component.videocard.SeasonCard import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.resizedImageUrl import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.pgc.FeedListType import dev.aaa1115910.bv.viewmodel.pgc.PgcViewModel @Composable fun PgcScaffold( modifier: Modifier = Modifier, lazyListState: LazyListState, pgcViewModel: PgcViewModel, pgcType: PgcType, featureButtons: (@Composable () -> Unit)? = null ) { val context = LocalContext.current val carouselFocusRequester = remember { FocusRequester() } val carouselFocusRestorer = rememberTvLazyListFocusRestorer(carouselFocusRequester) val currentFeedIndex = remember { mutableIntStateOf(0) } val carouselItems = pgcViewModel.carouselItems val pgcFeeds = pgcViewModel.feedItems ProvideListBringIntoViewSpec { LazyColumn( modifier = carouselFocusRestorer.containerModifier( modifier .fillMaxSize() .blockDownFocusExitAtGridEnd( currentIndex = currentFeedIndex.intValue, itemCount = pgcFeeds.size, columnCount = 1 ) ), state = lazyListState ) { item { Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.Center ) { PgcCarousel( modifier = Modifier .width(880.dp) .padding(32.dp, 0.dp) .focusRequester(carouselFocusRequester), data = carouselItems, onClick = { item -> SeasonInfoActivity.actionStart( context = context, epId = item.episodeId, seasonId = item.seasonId, proxyArea = ProxyArea.checkProxyArea(item.title) ) } ) } } if (featureButtons != null) { item { featureButtons() } } else { item { Spacer( modifier = Modifier .fillMaxWidth() .height(24.dp) ) } } itemsIndexed( items = pgcFeeds, key = { index, feedListItem -> when (feedListItem.type) { FeedListType.Ep -> "$index-ep-${feedListItem.items?.firstOrNull()?.seasonId ?: index}-${feedListItem.items?.size ?: 0}" FeedListType.Rank -> "$index-rank-${feedListItem.rank?.title ?: index}" } } ) { index, feedListItem -> Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp) .onFocusChanged { if (it.hasFocus) { currentFeedIndex.intValue = index if (index + 10 > pgcFeeds.size) { pgcViewModel.loadMore() } } }, contentAlignment = Alignment.Center ) { when (feedListItem.type) { FeedListType.Ep -> PgcFeedVideoRow( data = feedListItem.items!! ) FeedListType.Rank -> PgcFeedRankRow( data = feedListItem.rank!! ) } } } } } } @Composable fun PgcFeedVideoRow( modifier: Modifier = Modifier, data: List ) { val context = LocalContext.current val listFocusRestorer = rememberTvLazyListFocusRestorer() LazyRow( modifier = listFocusRestorer.containerModifier(modifier), contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(32.dp) ) { itemsIndexed( items = data, key = { index, feedItem -> "$index-season-${feedItem.seasonId}" } ) { index, feedItem -> val cardModifier = if (index == data.lastIndex) { Modifier.onPreviewKeyEvent { keyEvent -> when (keyEvent.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_RIGHT -> return@onPreviewKeyEvent true } false } } else { Modifier } SeasonCard( modifier = listFocusRestorer.firstItemModifier(index, cardModifier), coverHeight = 180.dp, data = SeasonCardData( seasonId = feedItem.seasonId, title = feedItem.title, subTitle = feedItem.subTitle, cover = feedItem.cover.resizedImageUrl(ImageSize.SeasonCoverThumbnail), rating = feedItem.rating ), onClick = { SeasonInfoActivity.actionStart( context = context, seasonId = feedItem.seasonId, proxyArea = ProxyArea.checkProxyArea(feedItem.title) ) } ) } } } @Composable fun PgcFeedRankRow( modifier: Modifier = Modifier, data: PgcFeedData.FeedRank ) { val context = LocalContext.current val listFocusRestorer = rememberTvLazyListFocusRestorer() Box( modifier = modifier .height(300.dp) ) { Box( modifier = Modifier .fillMaxSize() .background( Brush.verticalGradient( colors = listOf( // light theme color: Color(250, 222, 214) Color(20, 18, 17), Color(20, 18, 17).copy(alpha = 0.298f) ) ) ) ) {} BoxWithConstraints { AsyncImage( modifier = Modifier .fillMaxHeight() .offset(x = (-1 * (0.25 * 1.6 * this.maxHeight.value)).dp) .graphicsLayer { alpha = 0.99f } .drawWithContent { val colors = listOf( Color.Black, Color.Transparent ) drawContent() drawRect( brush = Brush.horizontalGradient(colors), blendMode = BlendMode.DstIn ) drawRect( brush = Brush.verticalGradient(colors), blendMode = BlendMode.DstIn ) }, model = data.cover, contentDescription = null, contentScale = ContentScale.FillHeight, alpha = 1f ) } Row( modifier = Modifier .fillMaxHeight(), verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier .fillMaxHeight() .width(240.dp) .padding(32.dp), verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.End ) { Text( text = data.title, style = MaterialTheme.typography.titleLarge, color = Color.White ) Text( text = data.subTitle, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.6f) ) } LazyRow( modifier = listFocusRestorer.containerModifier(modifier), contentPadding = PaddingValues(horizontal = 32.dp), horizontalArrangement = Arrangement.spacedBy(18.dp) ) { itemsIndexed( items = data.items, key = { index, feedItem -> "$index-season-${feedItem.seasonId}" } ) { index, feedItem -> val cardModifier = if (index == data.items.lastIndex) { Modifier.onPreviewKeyEvent { when (it.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_RIGHT -> return@onPreviewKeyEvent true } false } } else { Modifier } SeasonCard( modifier = listFocusRestorer.firstItemModifier(index, cardModifier), coverHeight = 180.dp, data = SeasonCardData( seasonId = feedItem.seasonId, title = feedItem.title, subTitle = feedItem.subTitle, cover = feedItem.cover.resizedImageUrl(ImageSize.SeasonCoverThumbnail), rating = feedItem.rating ), onClick = { SeasonInfoActivity.actionStart( context = context, seasonId = feedItem.seasonId, proxyArea = ProxyArea.checkProxyArea(feedItem.title) ) } ) } } } } } @Preview(device = "id:tv_1080p") @Composable fun PgcFeedRankRowPreview() { val data = PgcFeedData.FeedRank( cover = "http://i0.hdslb.com/bfs/archive/aae451dabf64ead2e983f92be76039a8ba233ade.png", title = "热门热血番剧榜", subTitle = "每小时更新", items = List(8) { PgcItem( cover = "https://i0.hdslb.com/bfs/bangumi/image/f610305ad3922bee9d51748ab38da0c54e785b44.png", title = "解雇后走上人生巅峰", subTitle = "被解雇的暗黑士兵慢生活的第二人生", episodeId = 0, seasonId = 0, seasonType = SeasonIndexType.Anime, rating = "9.8" ) } ) BVTheme { PgcFeedRankRow(data = data) } } @Composable fun PgcFeatureButtons( modifier: Modifier = Modifier, buttons: List Unit>> ) { val buttonWidth = 185.dp LazyRow( modifier = modifier .fillMaxWidth() .height(80.dp), horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), contentPadding = PaddingValues(horizontal = 32.dp) ) { itemsIndexed( items = buttons, key = { index, (title, _, _) -> "$index-feature-$title" } ) { _, (title, icon, onClick) -> when (icon) { is ImageVector -> PgcFeatureButton( modifier = Modifier.width(buttonWidth), title = title, icon = icon, onClick = { onClick.invoke() } ) is Painter -> PgcFeatureButton( modifier = Modifier.width(buttonWidth), title = title, icon = icon, onClick = { onClick.invoke() } ) else -> {} } } } } @Composable fun PgcFeatureButton( modifier: Modifier = Modifier, title: String, icon: ImageVector, onClick: () -> Unit ) { Surface( modifier = modifier, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = onClick ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon(imageVector = icon, contentDescription = null) Text( text = title, style = MaterialTheme.typography.titleLarge ) } } } } @Composable fun PgcFeatureButton( modifier: Modifier = Modifier, title: String, icon: Painter, onClick: () -> Unit ) { Surface( modifier = modifier, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, pressedContainerColor = MaterialTheme.colorScheme.inverseSurface ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = onClick ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( modifier = Modifier.size(24.dp), painter = icon, contentDescription = null ) Text( text = title, style = MaterialTheme.typography.titleLarge ) } } } } val showPlaceholderToast: () -> Unit = { "都说了介个是占位按钮了".toast(BVApp.context) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/PgcIndexScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import android.app.Activity import androidx.compose.animation.core.animateFloatAsState 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.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.pgc.IndexFilter import dev.aaa1115910.bv.tv.component.videocard.SeasonCard import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.getDisplayName import dev.aaa1115910.bv.viewmodel.index.PgcIndexViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable fun PgcIndexScreen( modifier: Modifier = Modifier, pgcIndexViewModel: PgcIndexViewModel = koinViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } val gridFocusRestorer = rememberTvLazyListFocusRestorer() var currentSeasonIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentSeasonIndex < 6 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) val pgcItems = pgcIndexViewModel.indexResultItems val noMore = pgcIndexViewModel.noMore var showFilter by remember { mutableStateOf(false) } val filterReady by remember { derivedStateOf { pgcIndexViewModel.isFilterReady } } val filterSignature by remember { derivedStateOf { pgcIndexViewModel.filterSignature } } val filterSections = pgcIndexViewModel.filterSections val selectedFilters = pgcIndexViewModel.selectedFilters val onLongClickSeason = { if (filterReady) { showFilter = true } } val reloadData = { scope.launch(Dispatchers.IO) { pgcIndexViewModel.clearData() pgcIndexViewModel.loadMore() } } LaunchedEffect(Unit) { val intent = (context as Activity).intent val pgcType = runCatching { PgcType.entries[intent.getIntExtra("pgcType", 0)] }.onFailure { logger.warn { "get pgcType from intent failed: ${it.stackTraceToString()}" } }.getOrDefault(PgcType.Anime) logger.fInfo { "index pgcType: $pgcType" } pgcIndexViewModel.changePgcType(pgcType) } LaunchedEffect(filterReady, filterSignature) { if (!filterReady) return@LaunchedEffect reloadData() } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(id = R.string.title_activity_pgc_index) + " - " + pgcIndexViewModel.pgcType.getDisplayName(context), fontSize = titleFontSize.sp, ) Text( text = stringResource(R.string.filter_dialog_open_tip), color = Color.White.copy(alpha = 0.6f) ) } } } ) { innerPadding -> ProvideListBringIntoViewSpec { LazyVerticalGrid( modifier = gridFocusRestorer.containerModifier( Modifier .padding(innerPadding) .blockDownFocusExitAtGridEnd( currentIndex = currentSeasonIndex, itemCount = pgcItems.size, columnCount = 6 ) ), columns = GridCells.Fixed(6), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp) ) { itemsIndexed( items = pgcItems, key = { index, pgcItem -> "$index-season-${pgcItem.seasonId}" } ) { index, pgcItem -> SeasonCard( modifier = gridFocusRestorer.firstItemModifier(index), data = SeasonCardData.fromPgcItem(pgcItem), onFocus = { currentSeasonIndex = index if (index + 30 > pgcItems.size) { println("load more by focus") scope.launch(Dispatchers.IO) { pgcIndexViewModel.loadMore() } } }, onClick = { SeasonInfoActivity.actionStart( context = context, seasonId = pgcItem.seasonId, proxyArea = ProxyArea.checkProxyArea(pgcItem.title) ) }, onLongClick = onLongClickSeason ) } if (pgcItems.isEmpty() && noMore) { item( span = { GridItemSpan(6) } ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text(text = stringResource(R.string.no_data)) OutlinedButton( enabled = filterReady, onClick = onLongClickSeason ) { Text(text = stringResource(R.string.filter_dialog_open_tip_click)) } } } } } } } } IndexFilter( type = pgcIndexViewModel.pgcType, show = showFilter && filterReady, onDismissRequest = { showFilter = false }, sections = filterSections, selectedFilters = selectedFilters, onFilterChange = { pgcIndexViewModel.updateFilter(it) }, onResetFilters = { pgcIndexViewModel.resetFilters() } ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/TvContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.runtime.Composable 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.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.pgc.PgcIndexActivity import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.viewmodel.pgc.PgcTvViewModel import org.koin.androidx.compose.koinViewModel @Composable fun TvContent( modifier: Modifier = Modifier, lazyListState: LazyListState, pgcViewModel: PgcTvViewModel = koinViewModel() ) { val context = LocalContext.current val onOpenIndex: () -> Unit = { PgcIndexActivity.actionStart(context = context, pgcType = PgcType.Tv) } PgcScaffold( lazyListState = lazyListState, pgcViewModel = pgcViewModel, pgcType = PgcType.Tv, featureButtons = { TvFeatureButtons( modifier = Modifier.padding(vertical = 24.dp), onOpenIndex = onOpenIndex ) } ) } @Composable private fun TvFeatureButtons( modifier: Modifier = Modifier, onOpenIndex: () -> Unit ) { val buttons = listOf( Triple( stringResource(R.string.anime_home_button_index), Icons.AutoMirrored.Rounded.List, onOpenIndex ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ) ) PgcFeatureButtons( modifier = modifier, buttons = buttons ) } @Preview(device = "id:tv_1080p") @Composable private fun TvFeatureButtonsPreview() { BVTheme { TvFeatureButtons( modifier = Modifier, onOpenIndex = {}, ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/VarietyContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.runtime.Composable 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.biliapi.entity.pgc.PgcType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.pgc.PgcIndexActivity import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.viewmodel.pgc.PgcVarietyViewModel import org.koin.androidx.compose.koinViewModel @Composable fun VarietyContent( modifier: Modifier = Modifier, lazyListState: LazyListState, pgcViewModel: PgcVarietyViewModel = koinViewModel() ) { val context = LocalContext.current val onOpenIndex: () -> Unit = { PgcIndexActivity.actionStart(context = context, pgcType = PgcType.Variety) } PgcScaffold( lazyListState = lazyListState, pgcViewModel = pgcViewModel, pgcType = PgcType.Variety, featureButtons = { VarietyFeatureButtons( modifier = Modifier.padding(vertical = 24.dp), onOpenIndex = onOpenIndex ) } ) } @Composable private fun VarietyFeatureButtons( modifier: Modifier = Modifier, onOpenIndex: () -> Unit ) { val buttons = listOf( Triple( stringResource(R.string.anime_home_button_index), Icons.AutoMirrored.Rounded.List, onOpenIndex ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ), Triple( stringResource(R.string.pgc_home_button_unknown), Icons.Rounded.QuestionMark, showPlaceholderToast ) ) PgcFeatureButtons( modifier = modifier, buttons = buttons ) } @Preview(device = "id:tv_1080p") @Composable private fun VarietyFeatureButtonsPreview() { BVTheme { VarietyFeatureButtons( modifier = Modifier, onOpenIndex = {}, ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/pgc/anime/AnimeTimelineScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.pgc.anime import androidx.compose.animation.core.animateFloatAsState 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.fillMaxWidth 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.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.biliapi.entity.season.Timeline import dev.aaa1115910.biliapi.entity.season.TimelineFilter import dev.aaa1115910.biliapi.repositories.SeasonRepository import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.videocard.SeasonCard import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.addAllWithMainContext import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.resizedImageUrl import dev.aaa1115910.bv.util.toast import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.compose.getKoin @Composable fun AnimeTimelineScreen( modifier: Modifier = Modifier, seasonRepository: SeasonRepository = getKoin().get() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } val listState = rememberLazyListState() val defaultFocusRequester = remember { FocusRequester() } val listFocusRestorer = rememberTvLazyListFocusRestorer(defaultFocusRequester) var currentTimelineIndex by remember { mutableIntStateOf(0) } var currentEpisodeIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentTimelineIndex == 0 && currentEpisodeIndex < 1 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) val timelines = remember { mutableStateListOf() } LaunchedEffect(Unit) { scope.launch(Dispatchers.IO) { runCatching { timelines.addAllWithMainContext { seasonRepository.getTimeline( filter = TimelineFilter.Anime, preferApiType = Prefs.apiType ) } runCatching { delay(200) logger.info { "scroll to item today" } // web 接口可以获取到最大 7 天前的数据,而 app 接口只能从 6 天前开始获取 val targetIndex = when (Prefs.apiType) { ApiType.Web -> 7 ApiType.App -> 6 } withContext(Dispatchers.Main) { listState.animateScrollToItem(targetIndex, 0) defaultFocusRequester.requestFocus(scope) } } }.onFailure { logger.fInfo { "Get timeline failed: ${it.stackTraceToString()}" } withContext(Dispatchers.Main) { "获取放送时间表失败: ${it.message}".toast(context) } } } } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp) ) { Text( text = stringResource(id = R.string.title_activity_anime_timeline), fontSize = titleFontSize.sp, ) } } ) { innerPadding -> LazyColumn( state = listState, modifier = listFocusRestorer.containerModifier(Modifier.padding(innerPadding)), contentPadding = PaddingValues(bottom = 48.dp, start = 48.dp, end = 48.dp) ) { itemsIndexed( items = timelines, key = { index, timeline -> "$index-timeline-${timeline.date.time}" } ) { index, timeline -> val defaultModifier = if (timeline.isToday) { Modifier.focusRequester(defaultFocusRequester) } else { Modifier } TimelinePerDay( modifier = defaultModifier, timeline = timeline, onFocusChange = { episodeIndex -> currentTimelineIndex = index currentEpisodeIndex = episodeIndex }, onClick = { seasonId -> SeasonInfoActivity.actionStart( context = context, seasonId = seasonId ) } ) } } } } @Composable fun TimelinePerDay( modifier: Modifier = Modifier, timeline: Timeline, onFocusChange: (index: Int) -> Unit = {}, onClick: (seasonId: Int) -> Unit = {}, ) { var hasFocus by remember { mutableStateOf(false) } val titleColor = if (hasFocus) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) val titleFontSize by animateFloatAsState( targetValue = if (hasFocus) 30f else 20f, label = "title font size" ) val episodeChunkedList = timeline.episodes.chunked(5) val getWeekString: (Int) -> String = { dayOfWeek -> when (dayOfWeek) { 1 -> "周一" 2 -> "周二" 3 -> "周三" 4 -> "周四" 5 -> "周五" 6 -> "周六" 7 -> "周日" else -> "未知" } } Column( modifier = modifier .padding(top = 24.dp) .onFocusChanged { hasFocus = it.hasFocus }, verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( text = "${timeline.dateString} ${getWeekString(timeline.dayOfWeek)}", fontSize = titleFontSize.sp, color = titleColor ) episodeChunkedList.forEachIndexed { index, episodes -> Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 6.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween ) { episodes.forEach { episode -> SeasonCard( modifier = Modifier .weight(1f) .padding(horizontal = 8.dp), data = SeasonCardData( title = episode.title, cover = episode.cover.resizedImageUrl(ImageSize.SeasonCoverThumbnail), seasonId = episode.seasonId, rating = null ), onFocus = { onFocusChange(index) }, onClick = { onClick(episode.seasonId) } ) } repeat(5 - episodes.size) { Spacer(modifier = Modifier.weight(1f)) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/ugc/UgcChildRegionButtons.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.ugc import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.SuggestionChip import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.ugc.UgcTypeV2 import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.getDisplayName import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.toast import io.github.oshai.kotlinlogging.KotlinLogging @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun UgcChildRegionButtons( modifier: Modifier = Modifier, childUgcTypes: List ) { // val context = LocalContext.current // val logger = KotlinLogging.logger { } // // val onClickChildRegion: (UgcTypeV2) -> Unit = { ugcType -> // logger.fInfo { "onClickChildRegion: $ugcType" } // "占位".toast(context) // } // // UgcChildRegionButtonsContent( // modifier = modifier // .padding(vertical = 12.dp), // childUgcTypes = childUgcTypes, // onClickChildRegion = onClickChildRegion // ) } @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun UgcChildRegionButtonsContent( modifier: Modifier = Modifier, childUgcTypes: List, onClickChildRegion: (UgcTypeV2) -> Unit ) { val context = LocalContext.current val focusRequester = remember { FocusRequester() } LazyRow( modifier = modifier.focusRestorer(focusRequester), contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), ) { itemsIndexed( items = childUgcTypes, key = { index, ugcType -> "$index-ugc-${ugcType.name}" } ) { index, ugcType -> SuggestionChip( modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(focusRequester)), onClick = { onClickChildRegion(ugcType) } ) { Text(text = ugcType.getDisplayName(context)) } } } } @Preview(device = "id:tv_1080p") @Composable private fun UgcChildRegionButtonsPreview() { BVTheme { UgcChildRegionButtons( modifier = Modifier.fillMaxWidth(), childUgcTypes = UgcTypeV2.dougaList ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/ugc/UgcCommon.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.ugc import android.content.Context import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan 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.foundation.rememberScrollState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.dp import dev.aaa1115910.biliapi.entity.CarouselData import dev.aaa1115910.biliapi.entity.ugc.UgcItem import dev.aaa1115910.biliapi.entity.ugc.UgcTypeV2 import dev.aaa1115910.biliapi.entity.ugc.region.UgcFeedPage import dev.aaa1115910.biliapi.repositories.UgcRepository import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.UgcCarousel import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.R import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.ugc.UgcViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.compose.koinInject @Composable fun UgcRegionScaffold( modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState(), ugcViewModel: UgcViewModel, childRegionButtons: (@Composable () -> Unit)? = null ) { val context = LocalContext.current val videoInfoRepository: VideoInfoRepository = koinInject() val carouselFocusRestorer = rememberTvLazyListFocusRestorer() val cardFocusRestorer = rememberTvLazyListFocusRestorer() var currentFocusedIndex by remember { mutableIntStateOf(0) } val shouldLoadMore by remember { derivedStateOf { !ugcViewModel.ugcItems.isEmpty() && currentFocusedIndex + 12 > ugcViewModel.ugcItems.size } } val onLongClickVideo: (UgcItem) -> Unit = { ugcItem -> UpInfoActivity.actionStart( context, mid = ugcItem.authorId, name = ugcItem.author, face = ugcItem.authorFace ) } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { withContext(Dispatchers.IO) { ugcViewModel.loadMore() } } } val padding = dimensionResource(R.dimen.grid_padding) val spacedBy = dimensionResource(R.dimen.grid_spacedBy) ProvideListBringIntoViewSpec { LazyVerticalGrid( modifier = if (ugcViewModel.showCarousel && ugcViewModel.carouselItems.isNotEmpty()) { carouselFocusRestorer.containerModifier( modifier .fillMaxSize() .blockDownFocusExitAtGridEnd( currentIndex = currentFocusedIndex, itemCount = ugcViewModel.ugcItems.size, columnCount = 4 ) ) } else { cardFocusRestorer.containerModifier( modifier .fillMaxSize() .blockDownFocusExitAtGridEnd( currentIndex = currentFocusedIndex, itemCount = ugcViewModel.ugcItems.size, columnCount = 4 ) ) }, columns = GridCells.Fixed(4), state = lazyGridState, contentPadding = PaddingValues(padding), verticalArrangement = Arrangement.spacedBy(spacedBy), horizontalArrangement = Arrangement.spacedBy(spacedBy) ) { // 轮播图组件 if (ugcViewModel.showCarousel && ugcViewModel.carouselItems.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { UgcCarousel( modifier = carouselFocusRestorer.firstItemModifier(0, Modifier.fillMaxWidth()), data = ugcViewModel.carouselItems, onClick = { item -> videoInfoRepository.preloadedVideoList.clear() VideoInfoActivity.actionStart( context = context, aid = item.avid!! ) } ) } } // 子区域按钮 // if (childRegionButtons != null) { // item(span = { GridItemSpan(maxLineSpan) }) { // childRegionButtons() // } // } itemsIndexed( items = ugcViewModel.ugcItems, key = { index, item -> "$index-av-${item.aid}" } ) { index, item -> SmallVideoCard( modifier = cardFocusRestorer.firstItemModifier(index), data = remember(item.aid) { VideoCardData( avid = item.aid, title = item.title, cover = item.cover, play = item.play, danmaku = item.danmaku, upName = item.author, time = item.duration * 1000L, pubTime = item.pubTime ) }, onClick = { videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll( ugcViewModel.ugcItems.map { ugcItem -> VideoCardData( avid = ugcItem.aid, title = ugcItem.title, cover = ugcItem.cover, upName = ugcItem.author, upId = ugcItem.authorId, play = ugcItem.play, danmaku = ugcItem.danmaku, time = ugcItem.duration * 1000L, pubTime = ugcItem.pubTime ) } ) VideoInfoActivity.actionStart(context, item.aid) }, onLongClick = {onLongClickVideo(item) }, onFocus = { currentFocusedIndex = index } ) } if (ugcViewModel.updating) { item(span = { GridItemSpan(maxLineSpan) }) { // 网格里占整行 Box( modifier = Modifier .fillMaxWidth() .height(80.dp), contentAlignment = Alignment.Center ) { LoadingTip() } } } } } } data class UgcScaffoldState( val context: Context, val scope: CoroutineScope, val lazyGridState: LazyGridState, val ugcType: UgcTypeV2, private val ugcRepository: UgcRepository ) { companion object { val logger = KotlinLogging.logger { } // 保存每个ugcType的数据状态 private val dataCache = mutableMapOf>() private val pageCache = mutableMapOf() // 清除缓存,可以在内存不足或需要重新加载所有数据时调用 fun clearCache() { dataCache.clear() pageCache.clear() } } // val carouselItems = mutableStateListOf() val ugcItems = mutableStateListOf() var nextPage by mutableStateOf(pageCache[ugcType] ?: UgcFeedPage()) var hasMore by mutableStateOf(true) var updating by mutableStateOf(false) var dataInitialized by mutableStateOf(false) // var showCarousel by mutableStateOf(true) init { // 如果有缓存数据,则恢复 dataCache[ugcType]?.let { cachedItems -> if (cachedItems.isNotEmpty()) { ugcItems.addAll(cachedItems) dataInitialized = true logger.fInfo { "Restored ${cachedItems.size} items from cache for $ugcType" } } } } suspend fun initUgcRegionData() { loadUgcRegionData() } suspend fun loadUgcRegionData() { if (!hasMore && updating) return // 如果已经初始化了数据,就不再重新加载 if (dataInitialized) { logger.fInfo { "Data already initialized for $ugcType, skip loading" } return } updating = true logger.fInfo { "load ugc $ugcType region data" } runCatching { val data = withContext(Dispatchers.IO) { ugcRepository.getRegionFeedRcmd(ugcType, nextPage) } ugcItems.clear() ugcItems.addAll(data.items) nextPage = data.nextPage updateCache() dataInitialized = true hasMore = true // 初始化后加载更多内容 loadMore() }.onFailure { logger.fInfo { "load $ugcType data failed: ${it.stackTraceToString()}" } withContext(Dispatchers.Main) { "加载 $ugcType 数据失败: ${it.message}".toast(context) } hasMore = false }.also { updating = false } } // 将缓存更新逻辑提取为单独的函数 private fun updateCache() { dataCache[ugcType] = ugcItems.toList() pageCache[ugcType] = nextPage } fun reloadAll() { logger.fInfo { "reload all $ugcType data" } scope.launch { withContext(Dispatchers.IO) { dataCache.remove(ugcType) pageCache.remove(ugcType) } nextPage = UgcFeedPage() hasMore = true ugcItems.clear() dataInitialized = false // 重新初始化数据 initUgcRegionData() } } suspend fun loadMore() { if (!hasMore && updating) return updating = true runCatching { val data = withContext(Dispatchers.IO) { ugcRepository.getRegionFeedRcmd(ugcType, nextPage) } ugcItems.addAll(data.items) nextPage = data.nextPage hasMore = data.items.isNotEmpty() updateCache() }.onFailure { logger.fInfo { "load more $ugcType data failed: ${it.stackTraceToString()}" } withContext(Dispatchers.Main) { "加载 $ugcType 更多推荐失败: ${it.message}".toast(context) } }.also { updating = false } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/ugc/UgcContentFactory.kt ================================================ package dev.aaa1115910.bv.tv.screens.main.ugc import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.aaa1115910.biliapi.entity.ugc.UgcTypeV2 import dev.aaa1115910.bv.tv.component.UgcTopNavItem import dev.aaa1115910.bv.viewmodel.ugc.UgcViewModel /** * 通用UGC内容组件,用于替代所有重复的*Content.kt文件 */ @Composable fun GenericUgcContent( modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState(), ugcViewModel: UgcViewModel, childUgcTypes: List ) { UgcRegionScaffold( modifier = modifier, lazyGridState = lazyGridState, ugcViewModel = ugcViewModel, childRegionButtons = { UgcChildRegionButtons( modifier = Modifier.fillMaxWidth(), childUgcTypes = childUgcTypes ) } ) } /** * UGC内容工厂,根据TopNavItem创建对应的内容组件 */ @Composable fun CreateUgcContent( navItem: UgcTopNavItem, modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState(), ugcViewModel: UgcViewModel ) { val childUgcTypes = when (navItem) { UgcTopNavItem.Douga -> UgcTypeV2.dougaList UgcTopNavItem.Game -> UgcTypeV2.gameList UgcTopNavItem.Kichiku -> UgcTypeV2.kichikuList UgcTopNavItem.Music -> UgcTypeV2.musicList UgcTopNavItem.Dance -> UgcTypeV2.danceList UgcTopNavItem.Cinephile -> UgcTypeV2.cinephileList UgcTopNavItem.Ent -> UgcTypeV2.entList UgcTopNavItem.Knowledge -> UgcTypeV2.knowledgeList UgcTopNavItem.Tech -> UgcTypeV2.techList UgcTopNavItem.Information -> UgcTypeV2.informationList UgcTopNavItem.Food -> UgcTypeV2.foodList UgcTopNavItem.ShortPlay -> UgcTypeV2.shortplayList UgcTopNavItem.Car -> UgcTypeV2.carList UgcTopNavItem.Fashion -> UgcTypeV2.fashionList UgcTopNavItem.Sports -> UgcTypeV2.sportsList UgcTopNavItem.Animal -> UgcTypeV2.animalList UgcTopNavItem.Vlog -> UgcTypeV2.vlogList UgcTopNavItem.Painting -> UgcTypeV2.paintingList UgcTopNavItem.Ai -> UgcTypeV2.aiList UgcTopNavItem.Home -> UgcTypeV2.homeList UgcTopNavItem.Outdoors -> UgcTypeV2.outdoorsList UgcTopNavItem.Gym -> UgcTypeV2.gymList UgcTopNavItem.Handmake -> UgcTypeV2.handmakeList UgcTopNavItem.Travel -> UgcTypeV2.travelList UgcTopNavItem.Rural -> UgcTypeV2.ruralList UgcTopNavItem.Parenting -> UgcTypeV2.parentingList UgcTopNavItem.Health -> UgcTypeV2.healthList UgcTopNavItem.Emotion -> UgcTypeV2.emotionList UgcTopNavItem.LifeJoy -> UgcTypeV2.lifeJoyList UgcTopNavItem.LifeExperience -> UgcTypeV2.lifeExperienceList UgcTopNavItem.Mysticism -> UgcTypeV2.mysticismList } GenericUgcContent( modifier = modifier, lazyGridState = lazyGridState, ugcViewModel = ugcViewModel, childUgcTypes = childUgcTypes ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/main/ugc/UgcStateManager.kt ================================================ ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/search/SearchInputScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.search import androidx.activity.compose.BackHandler import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.horizontalScroll 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 dev.aaa1115910.bv.tv.screens.main.drawerItemFocusRequesters import dev.aaa1115910.bv.tv.screens.main.DrawerItem 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.Icon import androidx.tv.material3.IconButton import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.search.Hotword import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.db.SearchHistoryDB import dev.aaa1115910.bv.tv.activities.search.SearchResultActivity import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.search.SearchKeyword import dev.aaa1115910.bv.tv.component.search.SoftKeyboard import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.viewmodel.search.SearchInputViewModel import org.koin.androidx.compose.koinViewModel @Composable fun SearchInputScreen( modifier: Modifier = Modifier, defaultFocusRequester: FocusRequester, searchInputViewModel: SearchInputViewModel = koinViewModel() ) { val context = LocalContext.current val searchKeyword = searchInputViewModel.keyword val hotwords = searchInputViewModel.hotwords val searchHistories = searchInputViewModel.searchHistories val suggests = searchInputViewModel.suggests var enableProxy by remember { mutableStateOf(false) } var focusOnContent by remember { mutableStateOf(false) } val onSearch: (String) -> Unit = { keyword -> SearchResultActivity.actionStart(context, keyword, enableProxy) searchInputViewModel.keyword = keyword searchInputViewModel.addSearchHistory(keyword) } LaunchedEffect(searchKeyword) { searchInputViewModel.updateSuggests() } BackHandler(enabled = focusOnContent) { drawerItemFocusRequesters[DrawerItem.Search]?.requestFocus() } SearchInputScreenContent( modifier = modifier .onFocusChanged { focusOnContent = it.hasFocus }, defaultFocusRequester = defaultFocusRequester, searchKeyword = searchKeyword, onSearchKeywordChange = { searchInputViewModel.keyword = it }, onSearch = onSearch, showProxyOptions = Prefs.enableProxy, enableProxy = enableProxy, onEnableProxyChange = { enableProxy = it }, hotwords = hotwords, suggests = suggests, histories = searchHistories, onDeleteHistory = { searchInputViewModel.deleteSearchHistory(it) }, onDeleteAllHistories = { searchInputViewModel.deleteAllSearchHistories() } ) } @Composable private fun SearchInputScreenContent( modifier: Modifier = Modifier, defaultFocusRequester: FocusRequester, searchKeyword: String, onSearchKeywordChange: (String) -> Unit, onSearch: (String) -> Unit, showProxyOptions: Boolean, enableProxy: Boolean, onEnableProxyChange: (Boolean) -> Unit, hotwords: List, suggests: List, histories: List, onDeleteHistory: (SearchHistoryDB) -> Unit, onDeleteAllHistories: () -> Unit ) { Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.search_input_title), fontSize = 48.sp ) } } } ) { innerPadding -> Row( modifier = Modifier .padding(innerPadding) .padding(vertical = 8.dp) .padding(start = 24.dp) .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(20.dp) ) { SearchInput( firstButtonFocusRequester = defaultFocusRequester, searchKeyword = searchKeyword, onSearchKeywordChange = onSearchKeywordChange, onSearch = { onSearch(searchKeyword) }, showProxyOptions = showProxyOptions, enableProxy = enableProxy, onEnableProxyChange = onEnableProxyChange ) if (searchKeyword.isEmpty()) { SearchHotwords( hotwords = hotwords, onSearch = onSearch ) } else { SearchSuggestion( suggests = suggests, onSearch = onSearch ) } SearchHistory( modifier = Modifier .padding(end = 10.dp), histories = histories, onSearch = onSearch, onDelete = onDeleteHistory, onDeleteAll = onDeleteAllHistories ) } } } @Composable private fun SearchInput( modifier: Modifier = Modifier, firstButtonFocusRequester: FocusRequester, searchKeyword: String, onSearchKeywordChange: (String) -> Unit, onSearch: (String) -> Unit, showProxyOptions: Boolean, enableProxy: Boolean, onEnableProxyChange: (Boolean) -> Unit ) { var textFieldValue by remember { mutableStateOf( TextFieldValue( text = searchKeyword, selection = TextRange(searchKeyword.length) ) ) } LaunchedEffect(searchKeyword) { if (searchKeyword != textFieldValue.text) { textFieldValue = textFieldValue.copy( text = searchKeyword, selection = TextRange(searchKeyword.length) ) } } Box( modifier = modifier .width(280.dp) .fillMaxHeight() .focusGroup(), contentAlignment = Alignment.TopCenter ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { OutlinedTextField( modifier = Modifier .width(258.dp) .onFocusChanged { if (it.isFocused && textFieldValue.selection.end != textFieldValue.text.length) { textFieldValue = textFieldValue.copy( selection = TextRange(textFieldValue.text.length) ) } }, value = textFieldValue, onValueChange = { textFieldValue = it onSearchKeywordChange(it.text) }, maxLines = 1, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearch(searchKeyword) }), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.inverseSurface, cursorColor = MaterialTheme.colorScheme.inverseSurface ) ) SoftKeyboard( firstButtonFocusRequester = firstButtonFocusRequester, showSearchWithProxy = showProxyOptions, enableSearchWithProxy = enableProxy, onClick = { onSearchKeywordChange(searchKeyword + it) }, onClear = { onSearchKeywordChange("") }, onDelete = { if (searchKeyword.isNotEmpty()) { onSearchKeywordChange(searchKeyword.dropLast(1)) } }, onSearch = { onSearch(searchKeyword) }, onEnableSearchWithProxyChange = onEnableProxyChange ) } } } @Composable private fun SearchHotwords( modifier: Modifier = Modifier, hotwords: List, onSearch: (String) -> Unit ) { Column( modifier = modifier .width(250.dp) .fillMaxHeight() .focusGroup(), ) { Text( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), text = stringResource(R.string.search_input_hotword), style = MaterialTheme.typography.titleLarge ) LazyColumn( modifier = Modifier, contentPadding = PaddingValues(vertical = 4.dp) ) { itemsIndexed( items = hotwords, key = { index, hotword -> "$index-hotword-${hotword.showName}" } ) { _, hotword -> SearchKeyword( modifier = Modifier, keyword = hotword.showName, leadingIcon = hotword.icon ?: "", onClick = { onSearch(hotword.showName) } ) } } } } @Composable private fun SearchSuggestion( modifier: Modifier = Modifier, suggests: List, onSearch: (String) -> Unit ) { Column( modifier = modifier .width(250.dp) .fillMaxHeight() .focusGroup(), ) { Text( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), text = stringResource(R.string.search_input_suggest), style = MaterialTheme.typography.titleLarge ) LazyColumn( modifier = Modifier, contentPadding = PaddingValues(vertical = 4.dp) ) { itemsIndexed( items = suggests, key = { index, suggest -> "$index-suggest-$suggest" } ) { _, suggest -> SearchKeyword( modifier = Modifier, keyword = suggest, leadingIcon = "", onClick = { onSearch(suggest) } ) } } } } @Composable private fun SearchHistory( modifier: Modifier = Modifier, histories: List, onSearch: (String) -> Unit, onDelete: (SearchHistoryDB) -> Unit, onDeleteAll: () -> Unit ) { val focusManager = LocalFocusManager.current var deleteMode by remember { mutableStateOf(false) } var showDeleteAllConfirmDialog by remember { mutableStateOf(false) } Column( modifier = modifier .width(250.dp) .fillMaxHeight() .focusGroup(), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), text = stringResource(R.string.search_input_history), style = MaterialTheme.typography.titleLarge ) Row { if (deleteMode) { IconButton( onClick = { showDeleteAllConfirmDialog = true }, colors = ButtonDefaults.colors( containerColor = MaterialTheme.colorScheme.surface, ) ) { Icon(imageVector = Icons.Default.DeleteSweep, contentDescription = null) } } IconButton( onClick = { deleteMode = !deleteMode }, colors = ButtonDefaults.colors( containerColor = MaterialTheme.colorScheme.surface, ) ) { if (deleteMode) { Icon(imageVector = Icons.Default.Close, contentDescription = null) } else { Icon(imageVector = Icons.Default.Delete, contentDescription = null) } } } } LazyColumn( modifier = Modifier, contentPadding = PaddingValues(vertical = 4.dp) ) { itemsIndexed( items = histories, key = { index, searchHistory -> "$index-history-${searchHistory.id ?: searchHistory.keyword}" } ) { index, searchHistory -> SearchKeyword( modifier = Modifier, keyword = searchHistory.keyword, leadingIcon = "", onClick = { if (deleteMode) { if (index == histories.lastIndex) { focusManager.moveFocus(FocusDirection.Up) } onDelete(searchHistory) } else { onSearch(searchHistory.keyword) } }, trailingIcon = (@Composable { Icon( modifier = Modifier.size(16.dp), imageVector = Icons.Default.Delete, contentDescription = null ) }).takeIf { deleteMode } ) } } } if (showDeleteAllConfirmDialog) { TvAlertDialog( onDismissRequest = { showDeleteAllConfirmDialog = false }, title = { Text(text = stringResource(R.string.search_input_history_delete_all_confirm_dialog_title)) }, text = { Text(text = stringResource(R.string.search_input_history_delete_all_confirm_dialog_text)) }, confirmButton = { Button(onClick = { onDeleteAll() showDeleteAllConfirmDialog = false }) { Text(text = stringResource(R.string.search_input_history_delete_all_confirm_dialog_confirm_button)) } }, dismissButton = { Button(onClick = { showDeleteAllConfirmDialog = false }) { Text(text = stringResource(R.string.search_input_history_delete_all_confirm_dialog_cancel_button)) } } ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SearchInputScreenContentPreview() { BVTheme { Row { Spacer( modifier = Modifier .width(80.dp) .fillMaxHeight() .background(MaterialTheme.colorScheme.surfaceVariant) ) SearchInputScreenContent( modifier = Modifier, defaultFocusRequester = FocusRequester.Default, searchKeyword = "测试", onSearchKeywordChange = {}, onSearch = {}, showProxyOptions = true, enableProxy = false, onEnableProxyChange = {}, hotwords = listOf( Hotword("热搜1", "热搜1", null), Hotword("热搜2", "热搜2", null) ), suggests = listOf("建议1", "建议2"), histories = listOf( SearchHistoryDB(keyword = "历史1"), SearchHistoryDB(keyword = "历史2") ), onDeleteHistory = {}, onDeleteAllHistories = {} ) } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/search/SearchResultFilter.kt ================================================ package dev.aaa1115910.bv.tv.screens.search import android.content.Context import android.view.KeyEvent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults 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.setValue import androidx.compose.ui.Modifier import kotlinx.coroutines.delay import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.biliapi.repositories.SearchFilterDuration import dev.aaa1115910.biliapi.repositories.SearchFilterOrderType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.util.Partition import dev.aaa1115910.bv.util.PartitionUtil @Composable fun SearchResultVideoFilter( modifier: Modifier = Modifier, show: Boolean, onHideFilter: () -> Unit, selectedOrder: SearchFilterOrderType, selectedDuration: SearchFilterDuration, selectedPartition: Partition?, selectedChildPartition: Partition?, onSelectedOrderChange: (SearchFilterOrderType) -> Unit, onSelectedDurationChange: (SearchFilterDuration) -> Unit, onSelectedPartitionChange: (Partition?) -> Unit, onSelectedChildPartitionChange: (Partition?) -> Unit, ) { val context = LocalContext.current val partitions = remember { PartitionUtil.partitions } val defaultFocusRequester = remember { FocusRequester() } val durationFocusRequester = remember { FocusRequester() } val partitionFocusRequester = remember { FocusRequester() } val partitionChildFocusRequester = remember { FocusRequester() } // 用于防止对话框刚打开时误触发点击事件 var isDialogJustOpened by remember { mutableStateOf(false) } val filterRowSpace = 8.dp LaunchedEffect(show) { if (show) { isDialogJustOpened = true // 延迟请求焦点,避免打开对话框的按键事件被新获得焦点的组件消费 delay(100) defaultFocusRequester.requestFocus() // 等待一段时间后才允许点击事件 delay(200) isDialogJustOpened = false } } if (show) { TvAlertDialog( modifier = modifier .fillMaxWidth(0.8f), onDismissRequest = onHideFilter, title = { Text(text = stringResource(R.string.filter_dialog_title)) }, text = { Column { LazyRow( modifier = Modifier.onPreviewKeyEvent { if (it.key == Key.DirectionDown) { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { durationFocusRequester.requestFocus() return@onPreviewKeyEvent true } return@onPreviewKeyEvent true } false }, horizontalArrangement = Arrangement.spacedBy(filterRowSpace) ) { itemsIndexed( items = SearchFilterOrderType.webFilters, key = { index, orderType -> "$index-order-${orderType.name}" } ) { _, orderType -> FilterDialogFilterChip( focusRequester = defaultFocusRequester, selected = orderType == selectedOrder, onClick = { onSelectedOrderChange(orderType) }, label = { Text(text = orderType.getDisplayName(context)) }, enabled = !isDialogJustOpened ) } } LazyRow( modifier = Modifier.onPreviewKeyEvent { if (it.key == Key.DirectionDown) { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { partitionFocusRequester.requestFocus() return@onPreviewKeyEvent true } return@onPreviewKeyEvent true } if (it.key == Key.DirectionUp) { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { defaultFocusRequester.requestFocus() return@onPreviewKeyEvent true } return@onPreviewKeyEvent true } false }, horizontalArrangement = Arrangement.spacedBy(filterRowSpace) ) { itemsIndexed( items = SearchFilterDuration.entries, key = { index, duration -> "$index-duration-${duration.name}" } ) { _, duration -> FilterDialogFilterChip( focusRequester = durationFocusRequester, selected = duration == selectedDuration, onClick = { onSelectedDurationChange(duration) }, label = { Text(text = duration.getDisplayName(context)) }, enabled = !isDialogJustOpened ) } } LazyRow( modifier = Modifier.onPreviewKeyEvent { if (it.key == Key.DirectionDown) { if (selectedChildPartition == null) return@onPreviewKeyEvent false if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { partitionChildFocusRequester.requestFocus() return@onPreviewKeyEvent true } return@onPreviewKeyEvent true } if (it.key == Key.DirectionUp) { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { durationFocusRequester.requestFocus() return@onPreviewKeyEvent true } return@onPreviewKeyEvent true } false }, horizontalArrangement = Arrangement.spacedBy(filterRowSpace) ) { item { FilterDialogFilterChip( focusRequester = partitionFocusRequester, selected = null == selectedPartition, onClick = { onSelectedPartitionChange(null) onSelectedChildPartitionChange(null) }, label = { Text(text = "全部分区") }, enabled = !isDialogJustOpened ) } itemsIndexed( items = partitions, key = { index, partition -> "$index-partition-${partition.tid}" } ) { _, partition -> FilterDialogFilterChip( focusRequester = partitionFocusRequester, selected = partition == selectedPartition, onClick = { onSelectedPartitionChange(partition) onSelectedChildPartitionChange(null) }, label = { Text(text = partition.strRes) }, enabled = !isDialogJustOpened ) } } AnimatedVisibility(visible = selectedPartition != null) { LazyRow( modifier = Modifier.onPreviewKeyEvent { if (it.key == Key.DirectionUp) { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { partitionFocusRequester.requestFocus() return@onPreviewKeyEvent true } return@onPreviewKeyEvent true } false }, horizontalArrangement = Arrangement.spacedBy(filterRowSpace) ) { itemsIndexed( items = selectedPartition?.children ?: emptyList(), key = { index, partition -> "$index-child-${partition.tid}" } ) { _, partition -> FilterDialogFilterChip( focusRequester = partitionChildFocusRequester, selected = partition == selectedChildPartition, onClick = { onSelectedChildPartitionChange( if (partition != selectedChildPartition) partition else null ) }, label = { Text(text = partition.strRes) }, enabled = !isDialogJustOpened ) } } } } }, confirmButton = {}, properties = DialogProperties(usePlatformDefaultWidth = false) ) } BackHandler( enabled = show, onBack = onHideFilter ) } @Composable private fun FilterDialogFilterChip( modifier: Modifier = Modifier, focusRequester: FocusRequester, selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit, enabled: Boolean = true ) { var hasFocus by remember { mutableStateOf(false) } val focusRequesterModifier = if (selected) modifier.focusRequester(focusRequester) else modifier FilterChip( modifier = focusRequesterModifier.onFocusChanged { hasFocus = it.hasFocus }, selected = selected, onClick = { if (enabled) onClick() }, label = label, border = if (hasFocus) FilterChipDefaults.filterChipBorder( enabled = true, selected = selected, borderColor = MaterialTheme.colorScheme.border, borderWidth = 2.dp, selectedBorderColor = MaterialTheme.colorScheme.border, selectedBorderWidth = 2.dp ) else FilterChipDefaults.filterChipBorder( enabled = true, selected = selected ) ) } fun SearchFilterOrderType.getDisplayName(context: Context) = when (this) { SearchFilterOrderType.ComprehensiveSort -> context.getString(R.string.search_result_filter_order_type_comprehensive_sort) SearchFilterOrderType.MostClicks -> context.getString(R.string.search_result_filter_order_type_most_clicks) SearchFilterOrderType.LatestPublish -> context.getString(R.string.search_result_filter_order_type_latest_publish) SearchFilterOrderType.MostDanmaku -> context.getString(R.string.search_result_filter_order_type_most_danmaku) SearchFilterOrderType.MostFavorites -> context.getString(R.string.search_result_filter_order_type_most_favorites) SearchFilterOrderType.MostComment -> "最多评论" SearchFilterOrderType.MostLikes -> "最多点赞" } fun SearchFilterDuration.getDisplayName(context: Context) = when (this) { SearchFilterDuration.All -> context.getString(R.string.search_result_filter_duration_all) SearchFilterDuration.LessThan10Minutes -> context.getString(R.string.search_result_filter_duration_less_than_10) SearchFilterDuration.Between10And30Minutes -> context.getString(R.string.search_result_filter_duration_10_to_30) SearchFilterDuration.Between30And60Minutes -> context.getString(R.string.search_result_filter_duration_30_to_60) SearchFilterDuration.MoreThan60Minutes -> context.getString(R.string.search_result_filter_duration_more_than_60) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/search/SearchResultScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.search import android.app.Activity import android.content.Context import androidx.compose.animation.core.animateFloatAsState 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.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.biliapi.entity.live.LiveRoomItem import dev.aaa1115910.biliapi.entity.ugc.toSmartDate import dev.aaa1115910.biliapi.repositories.SearchType import dev.aaa1115910.biliapi.repositories.SearchTypeResult import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.NavSwitchMode import dev.aaa1115910.bv.tv.component.videocard.SeasonCard import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity import dev.aaa1115910.bv.tv.component.TopNav import dev.aaa1115910.bv.tv.component.TopNavItem import dev.aaa1115910.bv.tv.component.live.LiveRoomCard import dev.aaa1115910.bv.tv.screens.user.UpCard import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.focusedScale import dev.aaa1115910.bv.util.removeHtmlTags import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.viewmodel.search.SearchResultViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable fun SearchResultScreen( modifier: Modifier = Modifier, searchResultViewModel: SearchResultViewModel = koinViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) val tabRowFocusRequester = remember { FocusRequester() } val listFocusRestorer = rememberTvLazyListFocusRestorer() val searchTopNavItems = remember { SearchType.entries.map(::SearchTopNavItem) } var rowSize by remember { mutableIntStateOf(4) } var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < rowSize } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "Title font size" ) var searchKeyword by remember { mutableStateOf("") } val searchResult = when (searchResultViewModel.searchType) { SearchType.Video -> searchResultViewModel.videoSearchResult SearchType.MediaBangumi -> searchResultViewModel.mediaBangumiSearchResult SearchType.MediaFt -> searchResultViewModel.mediaFtSearchResult SearchType.BiliUser -> searchResultViewModel.biliUserSearchResult SearchType.LiveRoom -> searchResultViewModel.liveRoomSearchResult } var showFilter by remember { mutableStateOf(false) } val selectedOrder = searchResultViewModel.selectedOrder val selectedDuration = searchResultViewModel.selectedDuration val selectedPartition = searchResultViewModel.selectedPartition val selectedChildPartition = searchResultViewModel.selectedChildPartition val onClickResult: (SearchTypeResult.SearchTypeResultItem) -> Unit = { resultItem -> when (resultItem) { is SearchTypeResult.Video -> { VideoInfoActivity.actionStart( context = context, aid = resultItem.aid, fromSeason = false ) } is SearchTypeResult.Pgc -> { SeasonInfoActivity.actionStart( context = context, seasonId = resultItem.seasonId, proxyArea = ProxyArea.checkProxyArea(resultItem.title) ) } is SearchTypeResult.User -> { UpInfoActivity.actionStart( context = context, mid = resultItem.mid, name = resultItem.name, face = resultItem.avatar ) } is SearchTypeResult.LiveRoom -> { VideoPlayerV3Activity.actionStartLive( context = context, roomId = resultItem.roomId.toInt(), title = resultItem.title, upId = resultItem.uid, upName = resultItem.uname, upFace = resultItem.face, watchedNum = resultItem.online / 10 ) } else -> {} } } val backToTabRow: () -> Unit = { tabRowFocusRequester.requestFocus(scope) } val onLongClickSearchResultItem = { if (searchResultViewModel.searchType == SearchType.Video) { if (Prefs.apiType == ApiType.Web) showFilter = true } } LaunchedEffect(Unit) { val intent = (context as Activity).intent if (intent.hasExtra("keyword")) { searchKeyword = intent.getStringExtra("keyword") ?: "" val enableProxy = intent.getBooleanExtra("enableProxy", false) if (searchKeyword == "") context.finish() searchResultViewModel.enableProxySearchResult = enableProxy searchResultViewModel.keyword = searchKeyword } else { context.finish() } } LaunchedEffect(searchResultViewModel.searchType) { rowSize = when (searchResultViewModel.searchType) { SearchType.Video -> 4 SearchType.MediaBangumi, SearchType.MediaFt -> 6 SearchType.BiliUser -> 3 SearchType.LiveRoom -> 4 } } LaunchedEffect( selectedOrder, selectedDuration, selectedPartition, selectedChildPartition ) { logger.fInfo { "Start update search result because filter updated" } searchResultViewModel.update() } LaunchedEffect(currentIndex) { if (currentIndex + 12 > searchResult.count) { searchResultViewModel.loadMore(searchResult.type) } } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( modifier = Modifier.fillMaxWidth(0.7f), text = searchKeyword, fontSize = titleFontSize.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) Column( horizontalAlignment = Alignment.End, ) { if (searchResultViewModel.searchType == SearchType.Video) { Text( text = stringResource(R.string.filter_dialog_open_tip), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } Text( text = stringResource( R.string.load_data_count, searchResult.count ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } } ) { innerPadding -> Column( modifier = Modifier .padding(innerPadding) .fillMaxSize() ) { TopNav( paddingTop = 0.dp, items = searchTopNavItems, initialSelectedItem = searchTopNavItems.firstOrNull { it.searchType == searchResultViewModel.searchType }, navSwitchMode = navSwitchMode, tabFocusRequester = tabRowFocusRequester, onSelectedChanged = { selectedItem -> val selectedSearchType = (selectedItem as SearchTopNavItem).searchType if (searchResultViewModel.searchType != selectedSearchType) { scope.launch { searchResultViewModel.searchType = selectedSearchType searchResultViewModel.init(selectedSearchType) } } } ) ProvideListBringIntoViewSpec(padding = 26.dp) { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier( Modifier .blockDownFocusExitAtGridEnd( currentIndex = currentIndex, itemCount = searchResult.count, columnCount = rowSize ) .onPreviewKeyEvent { when (it.key) { Key.Back -> { if (it.type == KeyEventType.KeyUp) backToTabRow() return@onPreviewKeyEvent true } } false } ), columns = GridCells.Fixed(rowSize), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp) ) { itemsIndexed( items = when (searchResult.type) { SearchType.Video -> searchResult.videos SearchType.MediaBangumi -> searchResult.mediaBangumis SearchType.MediaFt -> searchResult.mediaFts SearchType.BiliUser -> searchResult.biliUsers SearchType.LiveRoom -> searchResult.liveRooms }, key = { index, item -> "$index-${searchResultItemKey(item)}" } ) { index, searchResultItem -> SearchResultListItem( modifier = listFocusRestorer.firstItemModifier(index), searchResult = searchResultItem, onClick = { onClickResult(searchResultItem) }, onLongClick = onLongClickSearchResultItem, onFocus = { currentIndex = index } ) } } } } } SearchResultVideoFilter( show = showFilter, onHideFilter = { showFilter = false }, selectedOrder = selectedOrder, selectedDuration = selectedDuration, selectedPartition = selectedPartition, selectedChildPartition = selectedChildPartition, onSelectedOrderChange = { searchResultViewModel.selectedOrder = it }, onSelectedDurationChange = { searchResultViewModel.selectedDuration = it }, onSelectedPartitionChange = { searchResultViewModel.selectedPartition = it }, onSelectedChildPartitionChange = { searchResultViewModel.selectedChildPartition = it } ) } private data class SearchTopNavItem( val searchType: SearchType ) : TopNavItem { override fun getDisplayName(context: Context): String { return searchType.getDisplayName(context) } } private fun searchResultItemKey(item: SearchTypeResult.SearchTypeResultItem): Any { return when (item) { is SearchTypeResult.Video -> "video-${item.aid}" is SearchTypeResult.Pgc -> "pgc-${item.seasonId}" is SearchTypeResult.User -> "user-${item.mid}" is SearchTypeResult.LiveRoom -> "live-${item.roomId}" else -> item.hashCode() } } @Composable private fun SearchResultListItem( modifier: Modifier = Modifier, searchResult: SearchTypeResult.SearchTypeResultItem, onClick: () -> Unit, onLongClick: () -> Unit, onFocus: () -> Unit ) { when (searchResult) { is SearchTypeResult.Video -> { SmallVideoCard( modifier = modifier, data = VideoCardData( avid = searchResult.aid, title = searchResult.title.removeHtmlTags(), cover = searchResult.cover, play = with(searchResult.play) { if (this == -1L) null else this }, danmaku = with(searchResult.danmaku) { if (this == -1) null else this }, upName = searchResult.author, time = searchResult.duration * 1000L, pubTime = searchResult.pubTime.toLong().toSmartDate() ), onClick = onClick, onLongClick = onLongClick, onFocus = onFocus ) } is SearchTypeResult.Pgc -> { SeasonCard( modifier = modifier, data = SeasonCardData( seasonId = searchResult.seasonId, title = searchResult.title.removeHtmlTags(), cover = searchResult.cover, rating = String.format("%.1f", searchResult.star) ), onClick = onClick, onLongClick = onLongClick, onFocus = onFocus ) } is SearchTypeResult.User -> { UpCard( modifier = modifier.focusedScale(0.95f), face = searchResult.avatar, sign = searchResult.sign, username = searchResult.name, onFocusChange = { if (it) onFocus() }, onClick = onClick, onLongClick = onLongClick ) } is SearchTypeResult.LiveRoom -> { LiveRoomCard( modifier = modifier, data = LiveRoomItem( roomId = searchResult.roomId.toInt(), uid = 0, // Not available directly in search result title = searchResult.title.removeHtmlTags(), uname = searchResult.uname, online = searchResult.online, userCover = "", systemCover = "", cover = searchResult.cover, face = searchResult.face, parentId = 0, parentName = "", areaId = 0, areaName = searchResult.areaName ?: "" ), onClick = onClick ) } else -> { } } } fun SearchType.getDisplayName(context: Context) = when (this) { SearchType.Video -> context.getString(R.string.search_result_type_name_video) SearchType.MediaBangumi -> context.getString(R.string.search_result_type_name_media_bangumi) SearchType.MediaFt -> context.getString(R.string.search_result_type_name_media_ft) SearchType.BiliUser -> context.getString(R.string.search_result_type_name_bili_user) SearchType.LiveRoom -> context.getString(R.string.search_result_type_name_live_room) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/LogsScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings import android.content.Context import android.content.res.Configuration import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.wifi.WifiManager import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged 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 androidx.compose.ui.unit.sp import androidx.tv.material3.ListItem import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.component.QrImage import dev.aaa1115910.bv.network.HttpServer import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.LogCatcherUtil import dev.aaa1115910.bv.util.swapList import dev.aaa1115910.bv.util.toast import java.io.File import java.net.Inet4Address import java.net.NetworkInterface @Composable fun LogsScreen( modifier: Modifier = Modifier ) { val context = LocalContext.current val scope = rememberCoroutineScope() var host by remember { mutableStateOf("x.x.x.x") } var port by remember { mutableIntStateOf(0) } val logs = remember { mutableStateListOf() } var currentSelectFile by remember { mutableStateOf(null) } var qrContent by remember { mutableStateOf("") } val updateQRCode = { val url = "http://$host:$port/api/logs/${currentSelectFile?.name}" qrContent = url } @Suppress("DEPRECATION") val getIpAddress: () -> String = { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val isWifi: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val s = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) s?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true } else { val networkInfo = connectivityManager.activeNetworkInfo networkInfo?.type == ConnectivityManager.TYPE_WIFI } var ip = "" if (isWifi) { val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val wifiInfo = wifiManager.connectionInfo val ipNum = wifiInfo.ipAddress ip = "${ipNum and 0xFF}.${ipNum shr 8 and 0xFF}.${ipNum shr 16 and 0xFF}.${ipNum shr 24 and 0xFF}" } else { val en = NetworkInterface.getNetworkInterfaces() while (en.hasMoreElements()) { val intf = en.nextElement() val enumIpAddr = intf.inetAddresses while (ip == "" && enumIpAddr.hasMoreElements()) { val inetAddress = enumIpAddr.nextElement() if (!inetAddress.isLoopbackAddress && inetAddress is Inet4Address) { ip = inetAddress.getHostAddress() ?: "" break } } } } ip } val updateLogs = { LogCatcherUtil.updateLogFiles() val newLogs = (LogCatcherUtil.manualFiles + LogCatcherUtil.crashFiles) .sortedByDescending { it.lastModified() } logs.swapList(newLogs) } LaunchedEffect(Unit) { host = getIpAddress() port = HttpServer.server?.engine?.resolvedConnectors()?.first()?.port ?: 0 updateLogs() } LogsScreenContent( modifier = modifier, qrContent = qrContent, clearQrContent = { qrContent = "" }, logs = logs, onFocusLogFile = { file -> currentSelectFile = file updateQRCode() }, onClickCreateLog = { LogCatcherUtil.logLogcat(manual = true) "Log created".toast(context) updateLogs() } ) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun LogsScreenContent( modifier: Modifier = Modifier, qrContent: String, clearQrContent: () -> Unit, logs: List, onFocusLogFile: (File) -> Unit, onClickCreateLog: () -> Unit ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(id = R.string.title_activity_logs), fontSize = 48.sp ) } } } ) { innerPadding -> Row( modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { Box( modifier = Modifier.weight(1f) ) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( horizontal = 36.dp, vertical = 12.dp ) ) { item { CreateLogItem( modifier = Modifier.focusRequester(focusRequester), onFocus = clearQrContent, onClick = onClickCreateLog ) } itemsIndexed( items = logs, key = { index, logFile -> "$index-${logFile.absolutePath}" } ) { _, logFile -> LogItem( filename = logFile.name, size = logFile.length(), onFocus = { onFocusLogFile(logFile) } ) } if (logs.isEmpty()) { item { Box( modifier = Modifier .fillMaxWidth() .height(200.dp), contentAlignment = Alignment.Center ) { Text(text = stringResource(R.string.log_list_empty)) } } } } } Box( modifier = Modifier .weight(1f) .fillMaxSize(), contentAlignment = Alignment.Center ) { if (qrContent.isNotBlank()) { QrImage( modifier = Modifier.size(240.dp), content = qrContent, showLoadingWhenContentChanged = false ) } else { Text( text = stringResource(R.string.log_qr_code_empty), ) } } } } } @Composable fun LogItem( modifier: Modifier = Modifier, filename: String, size: Long, onFocus: () -> Unit ) { ListItem( modifier = modifier .onFocusChanged { if (it.hasFocus) onFocus() }, selected = false, onClick = { /*TODO*/ }, headlineContent = { Text(text = filename) }, supportingContent = { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = if (filename.startsWith("logs_manual")) stringResource(R.string.log_type_manual) else stringResource(R.string.log_type_crash) ) Text( text = "${size / 1024} KB" ) } } ) } @Composable fun CreateLogItem( modifier: Modifier = Modifier, onFocus: () -> Unit, onClick: () -> Unit, ) { ListItem( modifier = modifier .onFocusChanged { if (it.hasFocus) onFocus() }, selected = false, onClick = onClick, headlineContent = { Text(text = stringResource(R.string.log_save_now_button)) } ) } @Preview @Composable fun LogItemPreview() { BVTheme { LogItem( filename = "logs_manual_3202-11-11_08:16:23.log", size = 2145, onFocus = {} ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun LogsScreenContentPreview() { BVTheme { LogsScreenContent( qrContent = "", clearQrContent = {}, logs = listOf( File("logs_manual_3202-11-11_08:16:23.log"), File("logs_crash_3202-11-11_08:16:23.log") ), onFocusLogFile = {}, onClickCreateLog = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/MediaCodecScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings import android.os.Build 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.PaddingValues 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.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.Scaffold 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreviewKeyEvent 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 androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.CodecInfoData import dev.aaa1115910.bv.util.CodecMedia import dev.aaa1115910.bv.util.CodecMode import dev.aaa1115910.bv.util.CodecType import dev.aaa1115910.bv.util.CodecUtil import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.swapList import java.util.Locale @Composable fun MediaCodecScreen( modifier: Modifier = Modifier ) { val showLargeTitle by remember { derivedStateOf { true } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) var currentCodecInfoData by remember { mutableStateOf(null) } var focusInNav by remember { mutableStateOf(false) } val decoderList = remember { mutableStateListOf() } LaunchedEffect(Unit) { val list = CodecUtil.parseCodecs().filter { it.type == CodecType.Decoder } decoderList.swapList(list) currentCodecInfoData = list.firstOrNull() } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding( start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp ) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.title_activity_media_codec), fontSize = titleFontSize.sp ) Text( text = "", color = Color.White.copy(alpha = 0.6f) ) } } } ) { innerPadding -> Row( modifier = Modifier.padding(innerPadding) ) { MediaCodecListItems( modifier = Modifier .onFocusChanged { focusInNav = it.hasFocus } .weight(3f) .fillMaxHeight(), codecInfoDataList = decoderList, currentCodecInfoData = currentCodecInfoData, onCodecInfoDataChanged = { currentCodecInfoData = it }, isFocusing = focusInNav ) MediaCodecDetails( modifier = Modifier .weight(5f) .fillMaxSize(), onBackNav = { focusInNav = true }, currentCodecInfoData = currentCodecInfoData ) } } } @Composable fun MediaCodecListItems( modifier: Modifier = Modifier, codecInfoDataList: List, currentCodecInfoData: CodecInfoData?, onCodecInfoDataChanged: (CodecInfoData) -> Unit, isFocusing: Boolean ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } LaunchedEffect(isFocusing) { if (isFocusing && codecInfoDataList.isNotEmpty()) focusRequester.requestFocus(scope) } LaunchedEffect(codecInfoDataList) { if (codecInfoDataList.isNotEmpty()) focusRequester.requestFocus(scope) } LazyColumn( modifier = modifier, contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = codecInfoDataList, key = { index, codecInfoData -> "$index-$codecInfoData" } ) { _, codecInfoData -> val buttonModifier = if (currentCodecInfoData == codecInfoData) Modifier .focusRequester(focusRequester) .fillMaxWidth() else Modifier.fillMaxWidth() MediaCodecListItem( modifier = buttonModifier, codecInfoData = codecInfoData, onFocus = { onCodecInfoDataChanged(codecInfoData) }, selected = currentCodecInfoData == codecInfoData ) } } } @Composable fun MediaCodecListItem( modifier: Modifier = Modifier, codecInfoData: CodecInfoData, onFocus: () -> Unit, onLoseFocus: () -> Unit = {}, onClick: () -> Unit = {}, selected: Boolean ) { ListItem( modifier = modifier .onFocusChanged { if (it.hasFocus) onFocus() else onLoseFocus() }, selected = selected, onClick = onClick, headlineContent = { Text( modifier = Modifier.padding(horizontal = 16.dp), text = codecInfoData.name, //style = MaterialTheme.typography.titleLarge ) }, overlineContent = { Row( modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( modifier = Modifier .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(horizontal = 8.dp), text = codecInfoData.mimeType, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Icon( imageVector = when (codecInfoData.media) { CodecMedia.Audio -> Icons.Default.Audiotrack CodecMedia.Video -> Icons.Default.Videocam }, contentDescription = null ) } } ) } @Composable fun MediaCodecDetails( modifier: Modifier = Modifier, onBackNav: () -> Unit, currentCodecInfoData: CodecInfoData? ) { val context = LocalContext.current if (currentCodecInfoData != null) { LazyColumn( modifier = modifier .fillMaxSize() .onPreviewKeyEvent { val result = it.key.nativeKeyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT if (result) onBackNav() result }, verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues( horizontal = 48.dp, vertical = 24.dp ) ) { item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_hs_title), text = when (currentCodecInfoData.mode) { CodecMode.Hardware -> stringResource(R.string.codec_detail_hs_hardware) CodecMode.Software -> stringResource(R.string.codec_detail_hs_software) } ) } item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_max_supported_instances_title), text = currentCodecInfoData.maxSupportedInstances.toString() ) } if (currentCodecInfoData.media == CodecMedia.Video) { item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_color_formats_title), text = currentCodecInfoData.colorFormats.joinToString() ) } } if (currentCodecInfoData.media == CodecMedia.Audio) { item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_audio_bitrate_range_title), text = "${currentCodecInfoData.audioBitrateRange?.first?.toBps()} - ${currentCodecInfoData.audioBitrateRange?.last?.toBps()}" ) } } if (currentCodecInfoData.media == CodecMedia.Video) { item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_video_max_bitrate_title), text = currentCodecInfoData.videoBitrateRange?.last?.toBps() ?: "Unknown" ) } } if (currentCodecInfoData.media == CodecMedia.Video) { item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_video_frame_range_title), text = "${currentCodecInfoData.videoFrame?.first}fps - ${currentCodecInfoData.videoFrame?.last}fps" ) } } if (currentCodecInfoData.media == CodecMedia.Video) { item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_video_frame_supported_title), text = currentCodecInfoData.supportedFrameRates.joinToString("\n") { supportedFrameRate -> when (supportedFrameRate.resolution.second) { 360 -> context.getString(R.string.codec_detail_video_resolution_360p) 480 -> context.getString(R.string.codec_detail_video_resolution_480p) 720 -> context.getString(R.string.codec_detail_video_resolution_720p) 1080 -> context.getString(R.string.codec_detail_video_resolution_1080p) 1440 -> context.getString(R.string.codec_detail_video_resolution_1440p) 2160 -> context.getString(R.string.codec_detail_video_resolution_2160p) 4320 -> context.getString(R.string.codec_detail_video_resolution_4320p) else -> context.getString(R.string.codec_detail_video_resolution_unknown) } + ": " + ("${ String.format( Locale.getDefault(), "%.1f", supportedFrameRate.frameRate.upper ) }fps" .takeUnless { supportedFrameRate.unsupported } ?: context.getString(R.string.codec_detail_video_frame_unsupported)) } ) } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && currentCodecInfoData.media == CodecMedia.Video) { item { MediaCodecDetailItem( title = stringResource(R.string.codec_detail_video_frame_achievable_title), text = currentCodecInfoData.achievableFrameRates.joinToString("\n") { achievableFrameRates -> when (achievableFrameRates.resolution.second) { 360 -> context.getString(R.string.codec_detail_video_resolution_360p) 480 -> context.getString(R.string.codec_detail_video_resolution_480p) 720 -> context.getString(R.string.codec_detail_video_resolution_720p) 1080 -> context.getString(R.string.codec_detail_video_resolution_1080p) 1440 -> context.getString(R.string.codec_detail_video_resolution_1440p) 2160 -> context.getString(R.string.codec_detail_video_resolution_2160p) 4320 -> context.getString(R.string.codec_detail_video_resolution_4320p) else -> context.getString(R.string.codec_detail_video_resolution_unknown) } + ": " + ("${ String.format( Locale.getDefault(), "%.1f", achievableFrameRates.frameRate.upper ) }fps" .takeUnless { achievableFrameRates.unsupported } ?: context.getString(R.string.codec_detail_video_frame_unsupported)) } ) } } } } else { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = stringResource(R.string.codec_list_empty)) } } } @Composable fun MediaCodecDetailItem( modifier: Modifier = Modifier, title: String, text: String ) { var hasFocus by remember { mutableStateOf(false) } ListItem( modifier = modifier .onFocusChanged { hasFocus = it.hasFocus }, selected = hasFocus, onClick = {}, headlineContent = { Text(text = title) }, supportingContent = { Text(text = text) } ) } private val previewCodecInfoData = CodecInfoData( name = "c2.android.avc.decoder", type = CodecType.Decoder, mode = CodecMode.Hardware, media = CodecMedia.Video, mimeType = "video/avc", maxSupportedInstances = 1, colorFormats = listOf(21, 19, 20), audioBitrateRange = 0..0, videoBitrateRange = 0..0, videoFrame = 0..0, supportedFrameRates = emptyList(), achievableFrameRates = emptyList() ) @Preview(device = "id:tv_1080p") @Composable private fun MediaCodecListItemPreview() { BVTheme { MediaCodecListItem( codecInfoData = previewCodecInfoData, onFocus = {}, selected = false ) } } @Preview(device = "id:tv_1080p") @Composable private fun MediaCodecDetailsPreview() { BVTheme { MediaCodecDetails( currentCodecInfoData = previewCodecInfoData, onBackNav = {} ) } } private fun Int.toBps(): String { return when { this >= 1000000 -> "${this / 1000000} Mbps" this >= 1000 -> "${this / 1000} Kbps" else -> "$this bps" } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/SettingsScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings import android.content.Context import androidx.compose.animation.core.animateFloatAsState 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.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.lazy.LazyColumn import androidx.compose.material3.Scaffold 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreviewKeyEvent 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 androidx.compose.ui.unit.sp import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.screens.settings.content.AboutSetting import dev.aaa1115910.bv.tv.screens.settings.content.InfoSetting import dev.aaa1115910.bv.tv.screens.settings.content.NetworkSetting import dev.aaa1115910.bv.tv.screens.settings.content.OtherSetting import dev.aaa1115910.bv.tv.screens.settings.content.PlayerSetting import dev.aaa1115910.bv.tv.screens.settings.content.PlayerTypeSetting import dev.aaa1115910.bv.tv.screens.settings.content.StorageSetting import dev.aaa1115910.bv.tv.screens.settings.content.UISetting import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.requestFocus @Composable fun SettingsScreen( modifier: Modifier = Modifier ) { val showLargeTitle by remember { derivedStateOf { true } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) var currentMenu by remember { mutableStateOf(SettingsMenuNavItem.Player) } var focusInNav by remember { mutableStateOf(false) } Scaffold( modifier = modifier, ) { innerPadding -> Row( modifier = Modifier.padding(innerPadding) ) { Column( modifier = Modifier .weight(3f) .fillMaxHeight() ) { Text( modifier = Modifier.padding( start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp ), text = stringResource(R.string.title_activity_settings), fontSize = titleFontSize.sp ) SettingsNav( modifier = Modifier .onFocusChanged { focusInNav = it.hasFocus }, currentMenu = currentMenu, onMenuChanged = { currentMenu = it }, isFocusing = focusInNav ) } SettingContent( modifier = Modifier .weight(5f) .fillMaxSize() .padding(top = 48.dp), onBackNav = { focusInNav = true }, currentMenu = currentMenu ) } } } @Composable fun SettingsNav( modifier: Modifier = Modifier, currentMenu: SettingsMenuNavItem, onMenuChanged: (SettingsMenuNavItem) -> Unit, isFocusing: Boolean ) { val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } LaunchedEffect(isFocusing) { if (isFocusing) focusRequester.requestFocus(scope) } LaunchedEffect(Unit) { focusRequester.requestFocus(scope) } LazyColumn( modifier = modifier, contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { for (item in SettingsMenuNavItem.entries - listOf(SettingsMenuNavItem.PlayerType)) { val buttonModifier = if (currentMenu == item) Modifier .focusRequester(focusRequester) .fillMaxWidth() else Modifier.fillMaxWidth() item { SettingsMenuButton( modifier = buttonModifier, text = item.getDisplayName(context), selected = currentMenu == item, onFocus = { onMenuChanged(item) } ) } } } } enum class SettingsMenuNavItem(private val strRes: Int) { Player(R.string.settings_item_player), PlayerType(R.string.settings_item_player_type), UI(R.string.settings_item_ui), Other(R.string.settings_item_other), Storage(R.string.settings_item_storage), Network(R.string.settings_item_network), Info(R.string.settings_item_info), About(R.string.settings_item_about); fun getDisplayName(context: Context) = context.getString(strRes) } @Composable fun SettingContent( modifier: Modifier = Modifier, onBackNav: () -> Unit, currentMenu: SettingsMenuNavItem ) { Box( modifier = modifier .padding(24.dp) ) { SettingsDetail( modifier = Modifier.fillMaxSize(), onFocusBackMenuList = { onBackNav() } ) { when (currentMenu) { SettingsMenuNavItem.Player -> PlayerSetting() SettingsMenuNavItem.Info -> InfoSetting() SettingsMenuNavItem.About -> AboutSetting() SettingsMenuNavItem.Other -> OtherSetting() SettingsMenuNavItem.Network -> NetworkSetting() SettingsMenuNavItem.PlayerType -> PlayerTypeSetting() SettingsMenuNavItem.UI -> UISetting() SettingsMenuNavItem.Storage -> StorageSetting() } } } } @Composable fun SettingsMenuButton( modifier: Modifier = Modifier, text: String, onFocus: () -> Unit, onLoseFocus: () -> Unit = {}, onClick: () -> Unit = {}, selected: Boolean ) { ListItem( modifier = modifier .onFocusChanged { if (it.hasFocus) onFocus() else onLoseFocus() }, selected = selected, onClick = onClick, headlineContent = { Text( modifier = Modifier.padding( horizontal = 16.dp ), text = text, style = MaterialTheme.typography.titleLarge ) } ) } @Preview @Composable fun SettingsMenuButtonPreview() { BVTheme { Box( modifier = Modifier.size(200.dp, 100.dp) ) { SettingsMenuButton( modifier = Modifier.align(Alignment.Center), text = "This is button", selected = true, onFocus = {} ) } } } @Composable fun SettingsDetail( modifier: Modifier = Modifier, onFocusBackMenuList: () -> Unit, content: @Composable () -> Unit ) { Box( modifier = modifier .fillMaxSize() .onPreviewKeyEvent { val result = it.key.nativeKeyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT if (result) onFocusBackMenuList() result } ) { content() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/SpeedTestScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings import android.util.Base64 import android.webkit.CookieManager import android.webkit.WebView import android.webkit.WebView.setWebContentsDebuggingEnabled import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize 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.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import androidx.webkit.WebViewClientCompat import dev.aaa1115910.bv.R import dev.aaa1115910.bv.util.Prefs @Composable fun SpeedTestScreen( modifier: Modifier = Modifier ) { var loading by remember { mutableStateOf(true) } BoxWithConstraints { val width = with(LocalDensity.current) { this@BoxWithConstraints.maxWidth.toPx().toInt() } val webViewClient = object : WebViewClientCompat() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) //默认的 css 会无法正常显示 val css = """ .container { width: 1920px !important; height: 1080px !important; } """.trimIndent() val encoded: String = Base64.encodeToString(css.toByteArray(), Base64.NO_WRAP) view?.loadUrl( "javascript:(function() {" + "var parent = document.getElementsByTagName('head').item(0);" + "var style = document.createElement('style');" + "style.type = 'text/css';" + // Tell the browser to BASE64-decode the string into your script !!! "style.innerHTML = window.atob('" + encoded + "');" + "parent.appendChild(style)" + "})()" ) //处理完css还得处理缩放 view?.setInitialScale(((width / 1920f) * 100).toInt()) loading = false } } CookieManager.getInstance().apply { val cookies = mapOf( "DedeUserID" to Prefs.uid, "DedeUserID__ckMd5" to Prefs.uidCkMd5, "SESSDATA" to Prefs.sessData, "bili_jct" to Prefs.biliJct, "sid" to Prefs.sid ) cookies.forEach { (name, value) -> setCookie(".bilibili.com", "$name=$value") } } AndroidView( modifier = modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { this.webViewClient = webViewClient setWebContentsDebuggingEnabled(true) settings.apply { userAgentString = dev.aaa1115910.biliapi.BiliApiConstants.USER_AGENT_WEB javaScriptEnabled = true } loadUrl("https://www.bilibili.com/blackboard/video-diagnostics.html") } } ) if (loading) { Surface( modifier = Modifier.fillMaxSize(), colors = SurfaceDefaults.colors( containerColor = Color.Black.copy(alpha = 0.9f) ) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text(text = stringResource(R.string.loading)) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/AboutSetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content 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.fillMaxSize import androidx.compose.foundation.layout.height 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.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.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.BuildConfig import dev.aaa1115910.bv.R import dev.aaa1115910.bv.network.GithubApi import dev.aaa1115910.bv.tv.component.settings.UpdateDialog import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.fException import dev.aaa1115910.bv.util.fInfo import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable fun AboutSetting( modifier: Modifier = Modifier ) { val context = LocalContext.current val logger = KotlinLogging.logger("AboutSetting") var showUpdateDialog by remember { mutableStateOf(false) } var latestVersionName by remember { mutableStateOf("Loading...") } LaunchedEffect(Unit) { launch(Dispatchers.IO) { runCatching { latestVersionName = GithubApi.getLatestBuild().name if (latestVersionName.isEmpty()) { latestVersionName = GithubApi.getLatestBuild().tagName } logger.fInfo { "Find latest version $latestVersionName" } }.onFailure { logger.fException(it) { "Failed to get latest version" } latestVersionName = "Error" } } } Box( modifier = modifier ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.About.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = stringResource(R.string.about_statement), style = MaterialTheme.typography.titleMedium, color = Color.Red ) Text( text = stringResource( R.string.settings_version_current_version, "${BuildConfig.VERSION_NAME}.${BuildConfig.BUILD_TYPE}" ) ) Row( verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource( R.string.settings_version_latest_version, latestVersionName ) ) } } Button(onClick = { showUpdateDialog = true }) { Text(text = stringResource(R.string.settings_version_check_update_button)) } } Text( modifier = Modifier.align(Alignment.BottomCenter), text = "https://github.com/fantasytyx/bv" ) } UpdateDialog( show = showUpdateDialog, onHideDialog = { showUpdateDialog = false } ) } @Preview(device = "id:tv_1080p") @Composable private fun AboutSettingPreview() { BVTheme { AboutSetting() } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/InfoSetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content import android.app.Activity import android.app.ActivityManager import android.content.Context import android.content.Intent import android.os.Build import android.os.Environment import android.os.StatFs import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.settings.MediaCodecActivity import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import java.text.DecimalFormat import kotlin.math.pow @Composable fun InfoSetting( modifier: Modifier = Modifier ) { val context = LocalContext.current val memoryInfo by remember { mutableStateOf( lazy { runCatching { val memoryInfo = ActivityManager.MemoryInfo() (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) .getMemoryInfo(memoryInfo) val df = DecimalFormat("###.##") Pair( "${df.format(memoryInfo.availMem / 1024.0.pow(3))} GB", "${df.format(memoryInfo.totalMem / 1024.0.pow(3))} GB" ) }.getOrDefault(Pair("Unknown", "Unknown")) }.value ) } val storageInfo by remember { mutableStateOf( lazy { runCatching { val statFs = StatFs(Environment.getExternalStorageDirectory().absolutePath) val df = DecimalFormat("###.##") Pair( "${df.format(statFs.availableBytes / 1024.0.pow(3))} GB", "${df.format(statFs.totalBytes / 1024.0.pow(3))} GB" ) }.getOrDefault(Pair("Unknown", "Unknown")) }.value ) } @Suppress("DEPRECATION") val screenInfo by remember { mutableStateOf( lazy { val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { context.display!! } else { (context as Activity).windowManager.defaultDisplay } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val mode = display.mode Triple(mode.physicalWidth, mode.physicalHeight, mode.refreshRate) } else { Triple(display.width, display.height, display.refreshRate) } }.value ) } Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.Info.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) Column( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text(text = stringResource(R.string.settings_info_manufacturer, Build.MANUFACTURER)) Text( text = stringResource(R.string.settings_info_model, Build.MODEL, Build.PRODUCT) ) Text(text = stringResource(R.string.settings_info_system, Build.VERSION.RELEASE)) Text( text = stringResource( R.string.settings_info_screen, *arrayOf(screenInfo.first, screenInfo.second, screenInfo.third) ) ) if (Build.VERSION.SDK_INT >= 31) Text( text = stringResource( R.string.settings_info_soc, Build.SOC_MANUFACTURER, Build.SOC_MODEL ) ) Text( text = stringResource( R.string.settings_info_memory, *memoryInfo.toList().toTypedArray() ) ) Text( text = stringResource( R.string.settings_info_storage, *storageInfo.toList().toTypedArray() ) ) } Button(onClick = { context.startActivity(Intent(context, MediaCodecActivity::class.java)) }) { Text(stringResource(id = R.string.title_activity_media_codec)) } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/NetworkSetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content import android.content.Intent 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField 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.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.biliapi.http.BiliHttpProxyApi import dev.aaa1115910.biliapi.http.util.BiliDns import dev.aaa1115910.biliapi.repositories.ChannelRepository import dev.aaa1115910.biliapi.entity.ApiType import dev.aaa1115910.bv.BVApp import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.activities.settings.SpeedTestActivity import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.settings.SettingListItem import dev.aaa1115910.bv.tv.component.settings.SettingListItemWithDialog import dev.aaa1115910.bv.tv.component.settings.SettingSwitchListItem import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import org.koin.compose.getKoin @Composable fun NetworkSetting( modifier: Modifier = Modifier, channelRepository: ChannelRepository = getKoin().get() ) { val context = LocalContext.current var selectedApiType by remember { mutableStateOf(Prefs.apiType) } var enableProxy by remember { mutableStateOf(Prefs.enableProxy) } var proxyHttpServer by remember { mutableStateOf(Prefs.proxyHttpServer) } var proxyGRPCServer by remember { mutableStateOf(Prefs.proxyGRPCServer) } var preferOfficialCdn by remember { mutableStateOf(Prefs.preferOfficialCdn) } var ipv4Only by remember { mutableStateOf(Prefs.ipv4Only) } var showProxyHttpServerEditDialog by remember { mutableStateOf(false) } var showProxyGRPCServerEditDialog by remember { mutableStateOf(false) } Box( modifier = modifier ) { Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.Network.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { SettingListItemWithDialog( title = stringResource(R.string.settings_item_api), supportText = "", options = ApiType.entries, getDisplayName = { apiType, _ -> apiType.name }, value = selectedApiType, onValueChange = { selectedApiType = it Prefs.apiType = it } ) } item { Column { SettingSwitchListItem( title = stringResource(R.string.settings_network_enable_proxy_title), supportText = stringResource(R.string.settings_network_enable_proxy_text), checked = Prefs.enableProxy, onCheckedChange = { enable -> enableProxy = enable Prefs.enableProxy = enable if (enable) BVApp.instance?.initProxy() } ) AnimatedVisibility(visible = enableProxy) { Column { SettingListItem( modifier = Modifier.padding(top = 12.dp), title = stringResource(R.string.settings_network_proxy_http_server_title), supportText = if (proxyHttpServer.isBlank()) stringResource(R.string.settings_network_proxy_server_content_empty) else proxyHttpServer, onClick = { showProxyHttpServerEditDialog = true } ) SettingListItem( modifier = Modifier.padding(top = 12.dp), title = stringResource(R.string.settings_network_proxy_grpc_server_title), supportText = if (proxyGRPCServer.isBlank()) stringResource(R.string.settings_network_proxy_server_content_empty) else proxyGRPCServer, onClick = { showProxyGRPCServerEditDialog = true } ) } } } } item { SettingSwitchListItem( title = stringResource(R.string.settings_network_prefer_official_cdn_title), supportText = stringResource(R.string.settings_network_prefer_official_cdn_text), checked = Prefs.preferOfficialCdn, onCheckedChange = { enable -> preferOfficialCdn = enable Prefs.preferOfficialCdn = enable } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_network_ipv4_only_title), supportText = stringResource(R.string.settings_network_ipv4_only_text), checked = Prefs.ipv4Only, onCheckedChange = { enable -> ipv4Only = enable Prefs.ipv4Only = enable BiliDns.ipv4Only = enable } ) } item { SettingListItem( title = stringResource(R.string.settings_network_test_title), supportText = stringResource(R.string.settings_network_test_text), onClick = { context.startActivity(Intent(context, SpeedTestActivity::class.java)) } ) } } } } ProxyServerEditDialog( show = showProxyHttpServerEditDialog, onHideDialog = { showProxyHttpServerEditDialog = false }, title = stringResource(R.string.settings_network_proxy_http_server_title), proxyServer = proxyHttpServer, onProxyServerChange = { proxyHttpServer = it Prefs.proxyHttpServer = it BiliHttpProxyApi.createClient(it) } ) ProxyServerEditDialog( show = showProxyGRPCServerEditDialog, onHideDialog = { showProxyGRPCServerEditDialog = false }, title = stringResource(R.string.settings_network_proxy_grpc_server_title), proxyServer = proxyGRPCServer, onProxyServerChange = { proxyGRPCServer = it Prefs.proxyGRPCServer = it runCatching { channelRepository.initProxyChannel( accessKey = Prefs.accessToken, buvid = Prefs.buvid, proxyServer = it ) } } ) } @Composable fun ProxyServerEditDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, title: String, proxyServer: String, onProxyServerChange: (String) -> Unit ) { var proxyServerString by remember(show) { mutableStateOf(proxyServer) } if (show) { TvAlertDialog( modifier = modifier, title = { Text(text = title) }, text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp) ) { OutlinedTextField( value = proxyServerString, onValueChange = { proxyServerString = it }, singleLine = true, maxLines = 1, shape = MaterialTheme.shapes.medium, placeholder = { Text(text = stringResource(R.string.proxy_server_edit_dialog_input_field_label)) } ) Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) Text( text = stringResource(R.string.proxy_server_edit_dialog_warning), style = MaterialTheme.typography.bodySmall ) } } }, onDismissRequest = onHideDialog, confirmButton = { Button(onClick = { onProxyServerChange( proxyServerString .replace("\n", "") .replace("https://", "") .replace("http://", "") ) onHideDialog() }) { Text(text = stringResource(id = R.string.common_confirm)) } }, dismissButton = { OutlinedButton(onClick = onHideDialog) { Text(text = stringResource(id = R.string.common_cancel)) } } ) } } @Preview @Composable fun ProxyServerEditDialogPreview() { BVTheme { ProxyServerEditDialog( show = true, onHideDialog = {}, title = "title", proxyServer = "", onProxyServerChange = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/OtherSetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.BuildConfig import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.settings.SettingListItem import dev.aaa1115910.bv.tv.component.settings.SettingSwitchListItem import dev.aaa1115910.bv.tv.activities.settings.LogsActivity import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import dev.aaa1115910.bv.util.Prefs @Composable fun OtherSetting( modifier: Modifier = Modifier ) { val context = LocalContext.current var showFps by remember { mutableStateOf(Prefs.showFps) } var updateAlpha by remember { mutableStateOf(Prefs.updateAlpha) } Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.Other.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { SettingSwitchListItem( title = stringResource(R.string.settings_other_fps_title), supportText = stringResource(R.string.settings_other_fps_text), checked = showFps, onCheckedChange = { showFps = it Prefs.showFps = it } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_other_alpha_title), supportText = stringResource(R.string.settings_other_alpha_text), checked = updateAlpha, onCheckedChange = { updateAlpha = it Prefs.updateAlpha = it } ) } item { SettingListItem( title = stringResource(R.string.settings_create_logs_title), supportText = stringResource(R.string.settings_create_logs_text), onClick = { context.startActivity(Intent(context, LogsActivity::class.java)) } ) } if (BuildConfig.DEBUG) { item { SettingListItem( title = stringResource(R.string.settings_crash_test_title), supportText = stringResource(R.string.settings_crash_test_text), onClick = { throw Exception("Boom!") } ) } } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/PlayerSetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content import androidx.compose.foundation.background import androidx.compose.foundation.focusable 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.RadioButton import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.player.entity.Audio import dev.aaa1115910.bv.player.entity.PortraitVideoFixMode import dev.aaa1115910.bv.player.entity.PlayerLoadNextAction import dev.aaa1115910.bv.player.entity.PlayerDefaultStartPosition import dev.aaa1115910.bv.player.entity.NextVideoStrategy import dev.aaa1115910.bv.player.entity.NextVideoStrategyConfig import dev.aaa1115910.bv.player.entity.Resolution import dev.aaa1115910.bv.player.entity.VideoCodec import dev.aaa1115910.bv.player.entity.ControllerButtonConfig import dev.aaa1115910.bv.player.entity.DefaultSubtitle import dev.aaa1115910.bv.player.entity.LiveCodec import dev.aaa1115910.bv.player.entity.getControllerButtonConfigsForEditing import dev.aaa1115910.bv.player.entity.getControllerButtonDisplayName import dev.aaa1115910.bv.player.entity.serializeControllerButtonsOrder import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.settings.SettingListItem import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.tv.component.settings.SettingListItemWithDialog import dev.aaa1115910.bv.tv.component.settings.SettingSwitchListItem import dev.aaa1115910.bv.tv.component.settings.SettingNumberListItem import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import dev.aaa1115910.bv.util.Prefs @Composable fun PlayerSetting( modifier: Modifier = Modifier ) { val context = LocalContext.current var selectedResolution by remember { mutableStateOf(Prefs.defaultQuality) } var selectedVideoCodec by remember { mutableStateOf(Prefs.defaultVideoCodec) } var selectedAudio by remember { mutableStateOf(Prefs.defaultAudio) } var enableFfmpegAudioRenderer by remember { mutableStateOf(Prefs.enableFfmpegAudioRenderer) } var playerShowBottomProgressBar by remember { mutableStateOf(Prefs.playerShowBottomProgressBar) } var playerShowDebugInfo by remember { mutableStateOf(Prefs.playerShowDebugInfo) } var playerExitWhenAllIsPlayed by remember { mutableStateOf(Prefs.playerExitWhenAllIsPlayed) } var showNextVideoStrategyDialog by remember { mutableStateOf(false) } var playerDefaultStartPosition by remember { mutableStateOf(Prefs.playerDefaultStartPosition) } var defaultPlaybackSpeed by remember { mutableDoubleStateOf(Prefs.defaultPlaySpeed.toDouble()) } var playerSeekForwardStep by remember { mutableDoubleStateOf(Prefs.playerSeekForwardStep.toDouble()) } var playerSeekBackwardStep by remember { mutableDoubleStateOf(Prefs.playerSeekBackwardStep.toDouble()) } var portraitVideoFixMode by remember { mutableStateOf(Prefs.portraitVideoFixMode) } var showOnlineViewerCountDialog by remember { mutableStateOf(false) } val showOnlineViewerCount by Prefs.showOnlineViewerCountFlow.collectAsState(Prefs.showOnlineViewerCount) var showLiveViewerCountTipDialog by remember { mutableStateOf(false) } val showLiveViewerCountTip by Prefs.showLiveViewerCountTipFlow.collectAsState(Prefs.showLiveViewerCountTip) var enableAsyncQueueing by remember { mutableStateOf(Prefs.enableAsyncQueueing) } var skipPgcIntroOutro by remember { mutableStateOf(Prefs.skipPgcIntroOutro) } var showControllerButtonDialog by remember { mutableStateOf(false) } var defaultSubtitle by remember { mutableStateOf(Prefs.defaultSubtitle) } var defaultLiveCodec by remember { mutableStateOf(Prefs.defaultLiveCodec) } var defaultDanmakuFilterLevel by remember { mutableStateOf(Prefs.defaultDanmakuFilterLevel) } var defaultLiveDanmakuFilterLevel by remember { mutableStateOf(Prefs.defaultLiveDanmakuFilterLevel) } var showLongPressActionDialog by remember { mutableStateOf(false) } var playerLongPressAction by remember { mutableIntStateOf(Prefs.playerLongPressAction) } var playerLongPressSpeed by remember { mutableDoubleStateOf(Prefs.playerLongPressSpeed.toDouble()) } Column( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.Player.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { SettingListItemWithDialog( title = stringResource(R.string.settings_item_resolution), supportText = stringResource(R.string.settings_item_resolution), options = Resolution.entries.reversed(), getDisplayName = { item, ctx -> item.getDisplayName(ctx) }, value = selectedResolution, onValueChange = { Prefs.defaultQuality = it selectedResolution = it } ) } item { SettingListItemWithDialog( title = stringResource(R.string.settings_item_codec), supportText = stringResource(R.string.settings_item_codec), options = VideoCodec.entries.filter { it != VideoCodec.DVH1 && it != VideoCodec.HVC1 }, getDisplayName = { item, ctx -> item.getDisplayName(ctx) }, value = selectedVideoCodec, onValueChange = { Prefs.defaultVideoCodec = it selectedVideoCodec = it } ) } item { SettingListItemWithDialog( title = stringResource(R.string.settings_item_audio), supportText = stringResource(R.string.settings_item_codec), options = Audio.entries, getDisplayName = { item, ctx -> item.getDisplayName(ctx) }, value = selectedAudio, onValueChange = { Prefs.defaultAudio = it selectedAudio = it } ) } item { SettingListItemWithDialog( title = stringResource(R.string.settings_item_live_codec), supportText = stringResource(R.string.settings_item_live_codec), options = LiveCodec.entries.toList(), getDisplayName = { item, ctx -> item.getDisplayName(ctx) }, value = defaultLiveCodec, onValueChange = { defaultLiveCodec = it Prefs.defaultLiveCodec = it } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_other_ffmpeg_audio_renderer_title), supportText = stringResource(R.string.settings_other_ffmpeg_audio_renderer_text), checked = enableFfmpegAudioRenderer, onCheckedChange = { enableFfmpegAudioRenderer = it Prefs.enableFfmpegAudioRenderer = it } ) } item { SettingSwitchListItem( title = "启用异步缓冲队列", supportText = "减少丢帧和音频欠载,提升高帧率视频播放性能(Android 6.0-11 有效)", checked = enableAsyncQueueing, onCheckedChange = { enableAsyncQueueing = it Prefs.enableAsyncQueueing = it } ) } item { SettingListItemWithDialog( title = stringResource(R.string.settings_portrait_video_fix_mode_title), supportText = stringResource(R.string.settings_portrait_video_fix_mode_text), options = PortraitVideoFixMode.entries, getDisplayName = { item, ctx -> item.displayName(ctx) }, value = portraitVideoFixMode, onValueChange = { portraitVideoFixMode = it Prefs.portraitVideoFixMode = it } ) } item { SettingListItem( title = stringResource(R.string.settings_player_load_next_action_title), supportText = stringResource(R.string.settings_player_load_next_action_text), onClick = { showNextVideoStrategyDialog = true } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_player_exit_when_all_is_played_title), supportText = stringResource(R.string.settings_player_exit_when_all_is_played_text), checked = playerExitWhenAllIsPlayed, onCheckedChange = { playerExitWhenAllIsPlayed = it Prefs.playerExitWhenAllIsPlayed = it } ) } item { SettingListItem( title = "长按确认键行为", supportText = "设置播放器中长按确认键的行为", valueText = when (playerLongPressAction) { 0 -> "打开菜单" 1 -> "加速播放" else -> "打开菜单" }, onClick = { showLongPressActionDialog = true } ) } if (playerLongPressAction == 1) { item { SettingNumberListItem( title = "长按加速速度", supportText = "长按确认键时的播放速度", value = playerLongPressSpeed, minValue = 1.25, maxValue = 3.0, isInteger = false, step = 0.25, onValueChange = { playerLongPressSpeed = it Prefs.playerLongPressSpeed = it.toFloat() } ) } } item { SettingListItemWithDialog( title = stringResource(R.string.settings_player_default_start_position_title), supportText = stringResource(R.string.settings_player_default_start_position_text), options = PlayerDefaultStartPosition.entries, getDisplayName = { item, ctx -> item.displayName(ctx) }, value = playerDefaultStartPosition, onValueChange = { playerDefaultStartPosition = it Prefs.playerDefaultStartPosition = it } ) } item { SettingSwitchListItem( title = "跳过 PGC 片头片尾", supportText = "自动跳过 PGC 片头片尾", checked = skipPgcIntroOutro, onCheckedChange = { skipPgcIntroOutro = it Prefs.skipPgcIntroOutro = it } ) } item { SettingListItemWithDialog( title = "默认字幕", supportText = "首次播放时自动加载的字幕语言,仅加载非AI生成的字幕", options = DefaultSubtitle.entries, getDisplayName = { item, _ -> item.displayName() }, value = defaultSubtitle, onValueChange = { defaultSubtitle = it Prefs.defaultSubtitle = it } ) } item { SettingNumberListItem( title = stringResource(R.string.settings_player_default_playback_speed_title), supportText = stringResource(R.string.settings_player_default_playback_speed_text), value = defaultPlaybackSpeed, minValue = 0.25, maxValue = 2.5, isInteger = false, step = 0.25, onValueChange = { defaultPlaybackSpeed = it Prefs.defaultPlaySpeed = it.toFloat() } ) } item { SettingNumberListItem( title = stringResource(R.string.settings_player_seek_forward_step_title), supportText = stringResource(R.string.settings_player_seek_forward_step_text), value = playerSeekForwardStep, minValue = 5.0, maxValue = 30.0, isInteger = true, step = 1.0, onValueChange = { playerSeekForwardStep = it Prefs.playerSeekForwardStep = it.toInt() } ) } item { SettingNumberListItem( title = stringResource(R.string.settings_player_seek_backward_step_title), supportText = stringResource(R.string.settings_player_seek_backward_step_text), value = playerSeekBackwardStep, minValue = 5.0, maxValue = 30.0, isInteger = true, step = 1.0, onValueChange = { playerSeekBackwardStep = it Prefs.playerSeekBackwardStep = it.toInt() } ) } item { SettingNumberListItem( title = stringResource(R.string.settings_player_danmaku_filter_level_title), supportText = stringResource(R.string.settings_player_danmaku_filter_level_text), value = defaultDanmakuFilterLevel.toDouble(), minValue = 0.0, maxValue = 10.0, isInteger = true, step = 1.0, onValueChange = { defaultDanmakuFilterLevel = it.toInt() Prefs.defaultDanmakuFilterLevel = it.toInt() } ) } item { SettingNumberListItem( title = stringResource(R.string.settings_live_danmaku_filter_level_title), supportText = stringResource(R.string.settings_live_danmaku_filter_level_text), value = defaultLiveDanmakuFilterLevel.toDouble(), minValue = 0.0, maxValue = 60.0, isInteger = true, step = 1.0, onValueChange = { defaultLiveDanmakuFilterLevel = it.toInt() Prefs.defaultLiveDanmakuFilterLevel = it.toInt() } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_player_show_debug_info_title), supportText = stringResource(R.string.settings_player_show_debug_info_text), checked = playerShowDebugInfo, onCheckedChange = { playerShowDebugInfo = it Prefs.playerShowDebugInfo = it } ) } item { SettingListItem( title = "视频在线观看人数", supportText = "设置播放器在线人数显示方式", valueText = when (showOnlineViewerCount) { 0 -> "不显示" 1 -> "30 秒后隐藏" 2 -> "始终显示" else -> "30 秒后隐藏" }, onClick = { showOnlineViewerCountDialog = true } ) } item { SettingListItem( title = "直播人气&高能观众", supportText = "设置直播人气和高能观众显示方式", valueText = when (showLiveViewerCountTip) { 0 -> "不显示" 1 -> "30 秒后隐藏" 2 -> "始终显示" else -> "30 秒后隐藏" }, onClick = { showLiveViewerCountTipDialog = true } ) } item { SettingListItem( title = "播放器控制栏按钮", supportText = "自定义控制栏按钮的显示、排序和默认焦点", onClick = { showControllerButtonDialog = true } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_player_show_bottom_progress_bar_title), supportText = stringResource(R.string.settings_player_show_bottom_progress_bar_text), checked = playerShowBottomProgressBar, onCheckedChange = { playerShowBottomProgressBar = it Prefs.playerShowBottomProgressBar = it } ) } } OnlineViewerCountDialog( show = showOnlineViewerCountDialog, onHideDialog = { showOnlineViewerCountDialog = false }, showOnlineViewerCount = showOnlineViewerCount, onShowOnlineViewerCountChange = { Prefs.showOnlineViewerCount = it } ) LiveViewerCountTipDialog( show = showLiveViewerCountTipDialog, onHideDialog = { showLiveViewerCountTipDialog = false }, showLiveViewerCountTip = showLiveViewerCountTip, onShowLiveViewerCountTipChange = { Prefs.showLiveViewerCountTip = it } ) PlayerControllerButtonDialog( show = showControllerButtonDialog, onHideDialog = { showControllerButtonDialog = false }, initialOrderString = Prefs.playerControllerButtonsOrder ) NextVideoStrategyEditDialog( show = showNextVideoStrategyDialog, onHideDialog = { showNextVideoStrategyDialog = false } ) LongPressActionDialog( show = showLongPressActionDialog, onHideDialog = { showLongPressActionDialog = false }, longPressAction = playerLongPressAction, onLongPressActionChange = { playerLongPressAction = it Prefs.playerLongPressAction = it } ) } } @Composable private fun OnlineViewerCountDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, showOnlineViewerCount: Int, onShowOnlineViewerCountChange: (Int) -> Unit ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = "视频在线观看人数") }, text = { Column { val options = listOf( "不显示" to 0, "30 秒后隐藏" to 1, "始终显示" to 2 ) options.forEach { (text, value) -> ListItem( selected = showOnlineViewerCount == value, onClick = { onShowOnlineViewerCountChange(value) }, headlineContent = { Text(text = text) }, trailingContent = { RadioButton( selected = showOnlineViewerCount == value, onClick = null ) } ) } } }, confirmButton = {} ) } } @Composable private fun LiveViewerCountTipDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, showLiveViewerCountTip: Int, onShowLiveViewerCountTipChange: (Int) -> Unit ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = "直播人气&高能观众") }, text = { Column { val options = listOf( "不显示" to 0, "30 秒后隐藏" to 1, "始终显示" to 2 ) options.forEach { (text, value) -> ListItem( selected = showLiveViewerCountTip == value, onClick = { onShowLiveViewerCountTipChange(value) }, headlineContent = { Text(text = text) }, trailingContent = { RadioButton( selected = showLiveViewerCountTip == value, onClick = null ) } ) } } }, confirmButton = {} ) } } @Composable private fun LongPressActionDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, longPressAction: Int, onLongPressActionChange: (Int) -> Unit ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = "长按确认键行为") }, text = { Column { val options = listOf( "打开菜单" to 0, "加速播放" to 1 ) options.forEach { (text, value) -> ListItem( selected = longPressAction == value, onClick = { onLongPressActionChange(value) }, headlineContent = { Text(text = text) }, trailingContent = { RadioButton( selected = longPressAction == value, onClick = null ) } ) } } }, confirmButton = {} ) } } @Composable private fun PlayerControllerButtonDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, initialOrderString: String ) { if (!show) return val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val listState = rememberLazyListState() val initialConfigs = remember(initialOrderString) { getControllerButtonConfigsForEditing(initialOrderString) } var buttonConfigs by remember { mutableStateOf(initialConfigs) } var selectedIndex by remember { mutableIntStateOf(0) } var enterDownTime by remember { mutableStateOf(0L) } var longPressHandled by remember { mutableStateOf(false) } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } // 自动滚动到选中项 LaunchedEffect(selectedIndex) { listState.animateScrollToItem( index = (selectedIndex - 2).coerceAtLeast(0) ) } TvAlertDialog( modifier = modifier, onDismissRequest = { Prefs.playerControllerButtonsOrder = serializeControllerButtonsOrder(buttonConfigs) onHideDialog() }, title = { Text(text = "控制栏按钮") }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { Key.DirectionLeft -> { if (selectedIndex > 0) { buttonConfigs = buttonConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex - 1] this[selectedIndex - 1] = temp } selectedIndex-- } true } Key.DirectionRight -> { if (selectedIndex < buttonConfigs.size - 1) { buttonConfigs = buttonConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex + 1] this[selectedIndex + 1] = temp } selectedIndex++ } true } Key.DirectionUp -> { if (selectedIndex > 0) selectedIndex-- true } Key.DirectionDown -> { if (selectedIndex < buttonConfigs.size - 1) selectedIndex++ true } Key.Enter, Key.DirectionCenter -> { if (enterDownTime == 0L) { // 首次按下,记录时间 enterDownTime = System.currentTimeMillis() longPressHandled = false } else if (!longPressHandled && System.currentTimeMillis() - enterDownTime >= 600) { // 在重复 KeyDown 期间检测到长按 longPressHandled = true buttonConfigs = buttonConfigs.toMutableList().apply { for (i in indices) { this[i] = this[i].copy( isDefaultFocus = i == selectedIndex ) } } } true } else -> false } } else if (it.type == KeyEventType.KeyUp) { when (it.key) { Key.Enter, Key.DirectionCenter -> { if (!longPressHandled && enterDownTime > 0L) { // 短按:切换显示/隐藏 val config = buttonConfigs[selectedIndex] buttonConfigs = buttonConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = !config.hidden) } } enterDownTime = 0L longPressHandled = false true } else -> false } } else false } ) { Text( text = "左右键排序 · 短按确认键显示/隐藏 · 长按确认键设为默认焦点", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState, verticalArrangement = Arrangement.spacedBy(4.dp) ) { itemsIndexed( items = buttonConfigs, key = { index, config -> "$index-button-${config.id}" } ) { index, config -> ControllerButtonEditRow( title = getControllerButtonDisplayName(config.id), hidden = config.hidden, isDefaultFocus = config.isDefaultFocus, selected = index == selectedIndex ) } } } }, confirmButton = {} ) } @Composable private fun ControllerButtonEditRow( title: String, hidden: Boolean, isDefaultFocus: Boolean, selected: Boolean ) { val shape = remember { RoundedCornerShape(12.dp) } val bgColor = if (selected) MaterialTheme.colorScheme.onBackground else Color.Transparent Row( modifier = Modifier .fillMaxWidth() .background(color = bgColor, shape = shape) .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = title, textDecoration = if (hidden) TextDecoration.LineThrough else null, color = if (selected) { MaterialTheme.colorScheme.background } else if (hidden) { MaterialTheme.colorScheme.onSurfaceVariant } else { MaterialTheme.colorScheme.onSurface } ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (isDefaultFocus) { Text( text = "默认焦点", style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.primary ) } if (hidden) { Text( text = "隐藏", style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.error ) } else { Text( text = "显示", style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurfaceVariant ) } } } } @Composable private fun NextVideoStrategyEditDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit ) { if (!show) return val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } var strategyConfigs by remember { mutableStateOf( run { val validOrdinals = NextVideoStrategy.entries.map { it.ordinalValue }.toSet() val parsedConfigs = Prefs.playerNextVideoStrategyOrder.split(",").mapNotNull { if (it.isBlank()) return@mapNotNull null val hidden = it.startsWith("-") val id = it.replace("-", "").toIntOrNull() ?: return@mapNotNull null if (id !in validOrdinals) return@mapNotNull null val strategy = NextVideoStrategy.fromOrdinal(id) if (strategy == NextVideoStrategy.SingleVideo) return@mapNotNull null NextVideoStrategyConfig(strategy, hidden, id) }.toMutableList() val allStrategies = NextVideoStrategy.entries.filter { it != NextVideoStrategy.SingleVideo } val existingIds = parsedConfigs.map { it.ordinal }.toSet() // 互斥对:启用其中一个时,另一个默认禁用 val mutualExclusivePairs = mapOf( NextVideoStrategy.PreloadedVideoList.ordinalValue to NextVideoStrategy.PreloadedVideoListReverse.ordinalValue, NextVideoStrategy.PreloadedVideoListReverse.ordinalValue to NextVideoStrategy.PreloadedVideoList.ordinalValue, NextVideoStrategy.PartAndEpisode.ordinalValue to NextVideoStrategy.PartAndEpisodeReverse.ordinalValue, NextVideoStrategy.PartAndEpisodeReverse.ordinalValue to NextVideoStrategy.PartAndEpisode.ordinalValue, ) val enabledIds = parsedConfigs.filter { !it.hidden }.map { it.ordinal }.toSet() allStrategies.forEach { strategy -> if (strategy.ordinalValue !in existingIds) { // 如果互斥对中的另一方已启用,则默认禁用 val pairOrdinal = mutualExclusivePairs[strategy.ordinalValue] val shouldHide = pairOrdinal != null && pairOrdinal in enabledIds parsedConfigs.add(NextVideoStrategyConfig(strategy, hidden = shouldHide, ordinal = strategy.ordinalValue)) } } parsedConfigs.toList() } ) } var selectedIndex by remember { mutableIntStateOf(0) } val listState = rememberLazyListState() LaunchedEffect(selectedIndex, strategyConfigs.size) { if (strategyConfigs.isEmpty()) return@LaunchedEffect val layoutInfo = listState.layoutInfo val viewportStart = layoutInfo.viewportStartOffset val viewportEnd = layoutInfo.viewportEndOffset val viewportSize = viewportEnd - viewportStart if (viewportSize <= 0) return@LaunchedEffect val visibleItems = layoutInfo.visibleItemsInfo val visibleCount = visibleItems.size if (visibleCount <= 0) return@LaunchedEffect val firstVisible = listState.firstVisibleItemIndex val selectedItemInfo = visibleItems.firstOrNull { it.index == selectedIndex } if (selectedItemInfo != null) { val itemStart = selectedItemInfo.offset val itemEnd = itemStart + selectedItemInfo.size if (itemStart < viewportStart) { listState.animateScrollToItem(index = selectedIndex, scrollOffset = 0) return@LaunchedEffect } if (itemEnd > viewportEnd) { val bottomAlignedOffset = (viewportSize - selectedItemInfo.size).coerceAtLeast(0) listState.animateScrollToItem(index = selectedIndex, scrollOffset = bottomAlignedOffset) return@LaunchedEffect } val middleIndex = firstVisible + visibleCount / 2 if (selectedIndex > middleIndex) { val maxFirstVisible = (strategyConfigs.size - visibleCount).coerceAtLeast(0) val targetFirstVisible = (selectedIndex - visibleCount / 2) .coerceIn(0, maxFirstVisible) if (targetFirstVisible != firstVisible) { listState.animateScrollToItem(index = targetFirstVisible) } } return@LaunchedEffect } val maxFirstVisible = (strategyConfigs.size - visibleCount).coerceAtLeast(0) val targetFirstVisible = (selectedIndex - visibleCount / 2) .coerceIn(0, maxFirstVisible) if (targetFirstVisible != firstVisible) { listState.animateScrollToItem(index = targetFirstVisible) } } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } TvAlertDialog( modifier = modifier, onDismissRequest = { Prefs.playerNextVideoStrategyOrder = strategyConfigs.joinToString(",") { config -> if (config.hidden) "-${config.ordinal}" else "${config.ordinal}" } onHideDialog() }, title = { Text(text = stringResource(R.string.settings_player_load_next_action_title)) }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { Key.DirectionLeft -> { if (selectedIndex > 0) { strategyConfigs = strategyConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex - 1] this[selectedIndex - 1] = temp } selectedIndex-- } true } Key.DirectionRight -> { if (selectedIndex < strategyConfigs.size - 1) { strategyConfigs = strategyConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex + 1] this[selectedIndex + 1] = temp } selectedIndex++ } true } Key.DirectionUp -> { if (selectedIndex > 0) selectedIndex-- true } Key.DirectionDown -> { if (selectedIndex < strategyConfigs.size - 1) selectedIndex++ true } Key.Enter, Key.DirectionCenter -> { val config = strategyConfigs[selectedIndex] val newHidden = !config.hidden strategyConfigs = strategyConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = newHidden) // 互斥处理:启用时自动禁用对应项 if (!newHidden) { val mutualExclusiveOrdinal = when (config.strategy) { NextVideoStrategy.PreloadedVideoList -> NextVideoStrategy.PreloadedVideoListReverse.ordinalValue NextVideoStrategy.PreloadedVideoListReverse -> NextVideoStrategy.PreloadedVideoList.ordinalValue NextVideoStrategy.PartAndEpisode -> NextVideoStrategy.PartAndEpisodeReverse.ordinalValue NextVideoStrategy.PartAndEpisodeReverse -> NextVideoStrategy.PartAndEpisode.ordinalValue else -> null } if (mutualExclusiveOrdinal != null) { val pairIndex = indexOfFirst { it.ordinal == mutualExclusiveOrdinal } if (pairIndex >= 0) { this[pairIndex] = this[pairIndex].copy(hidden = true) } } } } true } else -> false } } else false } ) { Text( text = "左右键排序 · 短按确认键禁用/启用", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState, verticalArrangement = Arrangement.spacedBy(4.dp) ) { itemsIndexed( items = strategyConfigs, key = { index, config -> "${index}-strategy-${config.ordinal}" } ) { index, config -> NextVideoStrategyEditRow( title = config.strategy.displayName(LocalContext.current), hidden = config.hidden, selected = index == selectedIndex ) } } } }, confirmButton = {} ) } @Composable private fun NextVideoStrategyEditRow( title: String, hidden: Boolean, selected: Boolean ) { val shape = remember { RoundedCornerShape(12.dp) } val bgColor = if (selected) MaterialTheme.colorScheme.onBackground else Color.Transparent Row( modifier = Modifier .fillMaxWidth() .background(color = bgColor, shape = shape) .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = title, textDecoration = if (hidden) TextDecoration.LineThrough else null, color = if (selected) { MaterialTheme.colorScheme.background } else if (hidden) { MaterialTheme.colorScheme.onSurfaceVariant } else { MaterialTheme.colorScheme.onSurface } ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (hidden) { Text( text = "禁用", style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.error ) } else { Text( text = "启用", style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurfaceVariant ) } } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/PlayerTypeSetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed 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 androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import dev.aaa1115910.bv.tv.component.LibVLCDownloaderDialog import dev.aaa1115910.bv.tv.component.settings.SettingsMenuSelectItem import dev.aaa1115910.bv.entity.PlayerType import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import dev.aaa1115910.bv.util.Prefs @Composable fun PlayerTypeSetting( modifier: Modifier = Modifier ) { val context = LocalContext.current var selectedPlayerType by remember { mutableStateOf(Prefs.playerType) } var showLibVLCDownloaderDialog by remember { mutableStateOf(false) } Box( modifier = modifier ) { Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.PlayerType.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = PlayerType.entries, key = { index, playerType -> "$index-player-${playerType.name}" } ) { _, playerType -> SettingsMenuSelectItem( text = playerType.name, selected = selectedPlayerType == playerType, onClick = { selectedPlayerType = playerType Prefs.playerType = playerType } ) } } } } LibVLCDownloaderDialog( show = showLibVLCDownloaderDialog, onHideDialog = { showLibVLCDownloaderDialog = false } ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/StorageSetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn 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.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.settings.SettingListItem import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import dev.aaa1115910.bv.util.LogCatcherUtil import dev.aaa1115910.bv.util.fInfo import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @Composable fun StorageSetting( modifier: Modifier = Modifier ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } var loading by remember { mutableStateOf(false) } var imageCacheSize by remember { mutableLongStateOf(0L) } var updateCacheSize by remember { mutableLongStateOf(0L) } var crashLogsSize by remember { mutableLongStateOf(0L) } //var libVLCCacheSize by remember { mutableLongStateOf(0L) } //var libVLCFileSize by remember { mutableLongStateOf(0L) } var showConfirmDialog by remember { mutableStateOf(false) } var clearFun: (() -> Unit)? by remember { mutableStateOf(null) } var content by remember { mutableStateOf("") } var size by remember { mutableLongStateOf(0L) } val calSize = { scope.launch(Dispatchers.IO) { val imageCacheDir = File(context.cacheDir, "image_cache") val updateCacheDir = File(context.cacheDir, "update_downloader") val crashLogsDir = File(context.filesDir, LogCatcherUtil.LOG_DIR) //val libVLCCacheDir = File(context.cacheDir, "libvlc_downloader") //val libVLCFileDir = File(context.filesDir, "vlc_libs") val newImageCacheSize = getFolderSize(imageCacheDir) val newUpdateCacheSize = getFolderSize(updateCacheDir) val newCrashLogsSize = getFolderSize(crashLogsDir) //val newLibVLCCacheSize = getFolderSize(libVLCCacheDir) //val newLibVLCFileSize = getFolderSize(libVLCFileDir) withContext(Dispatchers.Main) { imageCacheSize = newImageCacheSize updateCacheSize = newUpdateCacheSize crashLogsSize = newCrashLogsSize //libVLCCacheSize = newLibVLCCacheSize //libVLCFileSize = newLibVLCFileSize } } } val clearImageCaches: () -> Unit = { logger.fInfo { "clearImageCaches" } val imageCacheDir = File(context.cacheDir, "image_cache") imageCacheDir.deleteRecursively() } val clearCrashLogs: () -> Unit = { logger.fInfo { "clearCrashLogs" } val crashLogsDir = File(context.filesDir, LogCatcherUtil.LOG_DIR) crashLogsDir.deleteRecursively() } val clearOthersCaches: () -> Unit = { logger.fInfo { "clearOthersCaches" } val updateCacheDir = File(context.cacheDir, "update_downloader") //val libVLCCacheDir = File(context.cacheDir, "libvlc_downloader") updateCacheDir.deleteRecursively() //libVLCCacheDir.deleteRecursively() } //val clearLibVLCFiles: () -> Unit = { // logger.fInfo { "clearLibVLCFiles" } // val libVLCFileDir = File(context.filesDir, "vlc_libs") // libVLCFileDir.deleteRecursively() //} LaunchedEffect(Unit) { loading = true calSize() loading = false } Box(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.Storage.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { SettingListItem( title = stringResource(R.string.settings_storage_image_cache), supportText = if (loading) stringResource(R.string.settings_storage_calculating) else "${imageCacheSize / 1024 / 1024} MB", onClick = { clearFun = clearImageCaches content = context.getString(R.string.settings_storage_image_cache) size = imageCacheSize showConfirmDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_storage_others_cache), supportText = if (loading) stringResource(R.string.settings_storage_calculating) //else "${updateCacheSize + libVLCCacheSize / 1024 / 1024} MB", else "${updateCacheSize / 1024 / 1024} MB", onClick = { clearFun = clearOthersCaches content = context.getString(R.string.settings_storage_others_cache) size = updateCacheSize// + libVLCCacheSize showConfirmDialog = true } ) } //item { // SettingListItem( // title = stringResource(R.string.settings_storage_libvlc_files), // supportText = if (loading) stringResource(R.string.settings_storage_calculating) // else "${libVLCFileSize / 1024 / 1024} MB", // onClick = { // clearFun = clearLibVLCFiles // content = context.getString(R.string.settings_storage_libvlc_files) // size = libVLCFileSize // showConfirmDialog = true // } // ) //} item { SettingListItem( title = stringResource(R.string.settings_storage_crash_logs), supportText = if (loading) stringResource(R.string.settings_storage_calculating) else "${crashLogsSize / 1024 / 1024} MB", onClick = { clearFun = clearCrashLogs content = context.getString(R.string.settings_storage_crash_logs) size = crashLogsSize showConfirmDialog = true } ) } } } } ConfirmDeleteDialog( show = showConfirmDialog, onHideDialog = { showConfirmDialog = false }, content = content, size = size, clearFiles = { clearFun?.invoke() calSize() } ) } private fun getFolderSize(f: File): Long { var size: Long = 0 if (f.isDirectory) { for (file in f.listFiles()!!) { size += getFolderSize(file) } } else { size = f.length() } return size } @Composable private fun ConfirmDeleteDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, content: String, size: Long, clearFiles: () -> Unit ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = onHideDialog, title = { Text(text = "清除$content") }, text = { Text(text = "${size / 1024 / 1024} MB") }, confirmButton = { Button(onClick = { clearFiles() onHideDialog() }) { Text(text = "确定") } }, dismissButton = { OutlinedButton(onClick = onHideDialog) { Text(text = "取消") } } ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/settings/content/UISetting.kt ================================================ package dev.aaa1115910.bv.tv.screens.settings.content import androidx.compose.foundation.focusable 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.ArrowDropUp import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.RadioButton import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.activities.LauncherActivity import dev.aaa1115910.bv.entity.InterfaceMode import dev.aaa1115910.bv.entity.NavSwitchMode import dev.aaa1115910.bv.entity.ThemeType import dev.aaa1115910.bv.tv.component.PgcTopNavItem import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.UgcTopNavItem import dev.aaa1115910.bv.tv.component.settings.SettingListItem import dev.aaa1115910.bv.tv.component.settings.SettingNumberListItem import dev.aaa1115910.bv.tv.component.settings.SettingSwitchListItem import dev.aaa1115910.bv.tv.component.HomeTopNavItem import dev.aaa1115910.bv.tv.screens.main.DrawerItem import dev.aaa1115910.bv.tv.screens.settings.SettingsMenuNavItem import dev.aaa1115910.bv.tv.util.NavItemConfig import dev.aaa1115910.bv.tv.util.LiveNavItemConfig import dev.aaa1115910.bv.tv.util.getLiveNavItemDisplayName import dev.aaa1115910.bv.tv.util.parseCachedLiveAreaGroups import dev.aaa1115910.bv.tv.util.parseDrawerNavItemsOrderToConfig import dev.aaa1115910.bv.tv.util.parseLiveNavItemsOrderToConfig import dev.aaa1115910.bv.tv.util.parseNavItemsOrderToConfig import dev.aaa1115910.bv.tv.util.saveDrawerNavConfigs import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.requestFocus import kotlin.math.roundToInt @Composable fun UISetting( modifier: Modifier = Modifier ) { val context = LocalContext.current var showDensityDialog by remember { mutableStateOf(false) } var showThemeTypeDialog by remember { mutableStateOf(false) } var showInterfaceModeDialog by remember { mutableStateOf(false) } var showNavSwitchModeDialog by remember { mutableStateOf(false) } var showHomeNavItemsDialog by remember { mutableStateOf(false) } var showUgcNavItemsDialog by remember { mutableStateOf(false) } var showPgcNavItemsDialog by remember { mutableStateOf(false) } var showLiveNavItemsDialog by remember { mutableStateOf(false) } var showDrawerNavItemsDialog by remember { mutableStateOf(false) } val density by Prefs.densityFlow.collectAsState(context.resources.displayMetrics.widthPixels / 960f) val themeType by Prefs.themeTypeFlow.collectAsState(Prefs.themeType) val interfaceMode = Prefs.interfaceMode val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) var showUGCVideoInfo by remember { mutableStateOf(Prefs.showUGCVideoInfo) } var videoInfoHistoryIncludeFromPlayer by remember { mutableStateOf(Prefs.videoInfoHistoryIncludeFromPlayer) } var ugcVideoInfoHistoryCount by remember { mutableIntStateOf(Prefs.ugcVideoInfoHistoryCount) } var ugcVideoPlayerHistoryCount by remember { mutableIntStateOf(Prefs.ugcVideoPlayerHistoryCount) } Box(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( text = SettingsMenuNavItem.UI.getDisplayName(context), style = MaterialTheme.typography.displaySmall ) Spacer(modifier = Modifier.height(12.dp)) LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { SettingListItem( title = stringResource(R.string.settings_ui_interface_mode_title), supportText = stringResource(R.string.settings_ui_interface_mode_text), valueText = interfaceMode.getDisplayName(context), onClick = { showInterfaceModeDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_density_title), supportText = stringResource(R.string.settings_ui_density_text), valueText = density.toString(), onClick = { showDensityDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_theme_type_title), supportText = stringResource(R.string.settings_ui_theme_type_text), valueText = themeType.getDisplayName(context), onClick = { showThemeTypeDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_nav_switch_mode_title), supportText = stringResource(R.string.settings_ui_nav_switch_mode_text), valueText = navSwitchMode.getDisplayName(context), onClick = { showNavSwitchModeDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_drawer_nav_items_title), supportText = stringResource(R.string.settings_ui_drawer_nav_items_text), onClick = { showDrawerNavItemsDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_home_nav_items_title), supportText = stringResource(R.string.settings_ui_home_nav_items_text), onClick = { showHomeNavItemsDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_ugc_nav_items_title), supportText = stringResource(R.string.settings_ui_ugc_nav_items_text), onClick = { showUgcNavItemsDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_pgc_nav_items_title), supportText = stringResource(R.string.settings_ui_pgc_nav_items_text), onClick = { showPgcNavItemsDialog = true } ) } item { SettingListItem( title = stringResource(R.string.settings_ui_live_nav_items_title), supportText = stringResource(R.string.settings_ui_live_nav_items_text), onClick = { showLiveNavItemsDialog = true } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_show_ugc_video_info_title), supportText = stringResource(R.string.settings_show_ugc_video_info_text), checked = showUGCVideoInfo, onCheckedChange = { showUGCVideoInfo = it Prefs.showUGCVideoInfo = it } ) } if (showUGCVideoInfo) { item { SettingNumberListItem( title = stringResource(R.string.settings_ui_ugc_video_info_history_count_title), supportText = stringResource(R.string.settings_ui_ugc_video_info_history_count_text), value = ugcVideoInfoHistoryCount.toDouble(), minValue = 1.0, maxValue = 10.0, isInteger = true, step = 1.0, onValueChange = { ugcVideoInfoHistoryCount = it.toInt() Prefs.ugcVideoInfoHistoryCount = it.toInt() } ) } item { SettingSwitchListItem( title = stringResource(R.string.settings_ui_video_info_history_include_from_player_title), supportText = stringResource(R.string.settings_ui_video_info_history_include_from_player_text), checked = videoInfoHistoryIncludeFromPlayer, onCheckedChange = { videoInfoHistoryIncludeFromPlayer = it Prefs.videoInfoHistoryIncludeFromPlayer = it } ) } } if (!showUGCVideoInfo) { item { SettingNumberListItem( title = stringResource(R.string.settings_ui_ugc_video_player_history_count_title), supportText = stringResource(R.string.settings_ui_ugc_video_player_history_count_text), value = ugcVideoPlayerHistoryCount.toDouble(), minValue = 1.0, maxValue = 10.0, isInteger = true, step = 1.0, onValueChange = { ugcVideoPlayerHistoryCount = it.toInt() Prefs.ugcVideoPlayerHistoryCount = it.toInt() } ) } } } } } UIDensityDialog( show = showDensityDialog, onHideDialog = { showDensityDialog = false }, density = density, onDensityChange = { Prefs.density = it } ) ThemeTypeDialog( show = showThemeTypeDialog, onHideDialog = { showThemeTypeDialog = false }, themeType = themeType, onThemeTypeChange = { Prefs.themeType = it } ) InterfaceModeDialog( show = showInterfaceModeDialog, onHideDialog = { showInterfaceModeDialog = false }, interfaceMode = interfaceMode, onInterfaceModeChange = { if (it != interfaceMode) { Prefs.interfaceMode = it LauncherActivity.actionRestart(context) } } ) NavSwitchModeDialog( show = showNavSwitchModeDialog, onHideDialog = { showNavSwitchModeDialog = false }, navSwitchMode = navSwitchMode, onNavSwitchModeChange = { Prefs.navSwitchMode = it } ) HomeNavItemsEditDialog( show = showHomeNavItemsDialog, onHideDialog = { showHomeNavItemsDialog = false }, initialOrderString = Prefs.homeNavItemsOrder ) UgcNavItemsEditDialog( show = showUgcNavItemsDialog, onHideDialog = { showUgcNavItemsDialog = false }, initialOrderString = Prefs.ugcNavItemsOrder ) PgcNavItemsEditDialog( show = showPgcNavItemsDialog, onHideDialog = { showPgcNavItemsDialog = false }, initialOrderString = Prefs.pgcNavItemsOrder ) LiveNavItemsEditDialog( show = showLiveNavItemsDialog, onHideDialog = { showLiveNavItemsDialog = false }, initialOrderString = Prefs.liveNavItemsOrder ) DrawerNavItemsEditDialog( show = showDrawerNavItemsDialog, onHideDialog = { showDrawerNavItemsDialog = false }, initialOrderString = Prefs.drawerNavItemsOrder ) } @Composable private fun UIDensityDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, density: Float, onDensityChange: (Float) -> Unit ) { val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val defaultDensity by remember { mutableFloatStateOf(context.resources.displayMetrics.widthPixels / 960f) } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } // 这里得采用固定的 Density,否则会导致更改 Density 时,对话框反复重新加载 CompositionLocalProvider( LocalDensity provides Density( density = defaultDensity, fontScale = LocalDensity.current.fontScale ) ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_density_title)) }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .fillMaxWidth() .onPreviewKeyEvent { if (it.key == Key.DirectionUp || it.key == Key.DirectionDown) { if (it.type == KeyEventType.KeyDown) { var newDensity = if (it.key == Key.DirectionUp) density + 0.1f else density - 0.1f newDensity = (newDensity * 10).roundToInt() / 10f if (newDensity < 0.5f) newDensity = 0.5f if (newDensity > 5f) newDensity = 5f onDensityChange(newDensity) } } false }, horizontalAlignment = Alignment.CenterHorizontally ) { Icon(imageVector = Icons.Rounded.ArrowDropUp, contentDescription = null) Text(text = "$density") Icon(imageVector = Icons.Rounded.ArrowDropDown, contentDescription = null) } }, confirmButton = {} ) } } } @Composable fun ThemeTypeDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, themeType: ThemeType, onThemeTypeChange: (ThemeType) -> Unit ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_theme_type_title)) }, text = { Column { ThemeType.entries.forEach { ListItem( selected = themeType == it, onClick = { onThemeTypeChange(it) }, headlineContent = { Text(text = it.getDisplayName(LocalContext.current)) }, trailingContent = { RadioButton( selected = themeType == it, onClick = null ) } ) } } }, confirmButton = {} ) } } @Composable fun InterfaceModeDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, interfaceMode: InterfaceMode, onInterfaceModeChange: (InterfaceMode) -> Unit ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_interface_mode_title)) }, text = { Column { InterfaceMode.entries.forEach { ListItem( selected = interfaceMode == it, onClick = { onInterfaceModeChange(it) }, headlineContent = { Text(text = it.getDisplayName(LocalContext.current)) }, trailingContent = { RadioButton( selected = interfaceMode == it, onClick = null ) } ) } } }, confirmButton = {} ) } } @Composable fun NavSwitchModeDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, navSwitchMode: NavSwitchMode, onNavSwitchModeChange: (NavSwitchMode) -> Unit ) { if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_nav_switch_mode_title)) }, text = { Column { NavSwitchMode.entries.forEach { ListItem( selected = navSwitchMode == it, onClick = { onNavSwitchModeChange(it) }, headlineContent = { Text(text = it.getDisplayName(LocalContext.current)) }, trailingContent = { RadioButton( selected = navSwitchMode == it, onClick = null ) } ) } } }, confirmButton = {} ) } } @Preview @Composable fun UIDensityDialogPreview() { val show by remember { mutableStateOf(true) } var density by remember { mutableFloatStateOf(1.0f) } BVTheme { UIDensityDialog( show = show, onHideDialog = {}, density = density, onDensityChange = { density = it } ) } } @Preview @Composable private fun ThemeTypeDialogPreview() { val show by remember { mutableStateOf(true) } val themeType by remember { mutableStateOf(ThemeType.Auto) } BVTheme { ThemeTypeDialog( show = show, onHideDialog = {}, themeType = themeType, onThemeTypeChange = {} ) } } @Composable private fun HomeNavItemsEditDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, initialOrderString: String ) { if (!show) return val context = LocalContext.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } // 解析初始配置(按显示顺序) val initialConfigs = remember(initialOrderString) { parseNavItemsOrderToConfig(initialOrderString) } // 当前配置状态 var navConfigs by remember { mutableStateOf(initialConfigs) } // 当前选中的索引 var selectedIndex by remember { mutableIntStateOf(0) } // 默认标签 var defaultTabOrdinal by remember { mutableIntStateOf(Prefs.defaultHomeTab) } // 长按检测 var enterDownTime by remember { mutableStateOf(0L) } var longPressHandled by remember { mutableStateOf(false) } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } TvAlertDialog( modifier = modifier, onDismissRequest = { // 关闭时自动保存 Prefs.defaultHomeTab = defaultTabOrdinal saveNavConfigs(navConfigs, defaultTabOrdinal) onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_home_nav_items_title)) }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { Key.DirectionLeft -> { if (selectedIndex > 0) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex - 1] this[selectedIndex - 1] = temp } selectedIndex-- } true } Key.DirectionRight -> { if (selectedIndex < navConfigs.size - 1) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex + 1] this[selectedIndex + 1] = temp } selectedIndex++ } true } Key.DirectionUp -> { if (selectedIndex > 0) selectedIndex-- true } Key.DirectionDown -> { if (selectedIndex < navConfigs.size - 1) selectedIndex++ true } Key.Enter, Key.DirectionCenter -> { if (enterDownTime == 0L) { enterDownTime = System.currentTimeMillis() longPressHandled = false } else if (!longPressHandled && System.currentTimeMillis() - enterDownTime >= 600) { // 长按:设为默认标签,并取消隐藏 longPressHandled = true val config = navConfigs[selectedIndex] defaultTabOrdinal = config.ordinal if (config.hidden) { navConfigs = navConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = false) } } } true } else -> false } } else if (it.type == KeyEventType.KeyUp) { when (it.key) { Key.Enter, Key.DirectionCenter -> { if (!longPressHandled && enterDownTime > 0L) { // 短按:切换显示/隐藏(默认标签不可隐藏) val config = navConfigs[selectedIndex] if (config.ordinal != defaultTabOrdinal) { navConfigs = navConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = !config.hidden) } } } enterDownTime = 0L longPressHandled = false true } else -> false } } else false } ) { // 提示文字 Text( text = stringResource(R.string.settings_ui_home_nav_items_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) navConfigs.forEachIndexed { index, config -> val navItem = HomeTopNavItem.entries.getOrNull(config.ordinal) if (navItem != null) { val isSelected = index == selectedIndex val isDefaultHomeTab = config.ordinal == defaultTabOrdinal NavItemEditRow( title = navItem.getDisplayName(LocalContext.current), hidden = config.hidden, selected = isSelected, showDefaultTag = isDefaultHomeTab, onFocus = { selectedIndex = index } ) } } } }, confirmButton = {} ) } @Composable private fun UgcNavItemsEditDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, initialOrderString: String ) { if (!show) return val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val initialConfigs = remember(initialOrderString) { parseNavItemsOrderToConfig(initialOrderString, UgcTopNavItem.entries.size) } var navConfigs by remember { mutableStateOf(initialConfigs) } var selectedIndex by remember { mutableIntStateOf(0) } val listState = rememberLazyListState() LaunchedEffect(selectedIndex, navConfigs.size) { if (navConfigs.isEmpty()) return@LaunchedEffect val layoutInfo = listState.layoutInfo val viewportStart = layoutInfo.viewportStartOffset val viewportEnd = layoutInfo.viewportEndOffset val viewportSize = viewportEnd - viewportStart if (viewportSize <= 0) return@LaunchedEffect val visibleItems = layoutInfo.visibleItemsInfo val visibleCount = visibleItems.size if (visibleCount <= 0) return@LaunchedEffect val firstVisible = listState.firstVisibleItemIndex val selectedItemInfo = visibleItems.firstOrNull { it.index == selectedIndex } // 1) 先保证“选中项完全可见”(尤其是最后一项,避免底部被截断) if (selectedItemInfo != null) { val itemStart = selectedItemInfo.offset val itemEnd = itemStart + selectedItemInfo.size if (itemStart < viewportStart) { listState.animateScrollToItem(index = selectedIndex, scrollOffset = 0) return@LaunchedEffect } if (itemEnd > viewportEnd) { val bottomAlignedOffset = (viewportSize - selectedItemInfo.size).coerceAtLeast(0) listState.animateScrollToItem(index = selectedIndex, scrollOffset = bottomAlignedOffset) return@LaunchedEffect } // 2) 完全可见时,再按“到可见列表中间才开始滚动”的规则微调 val middleIndex = firstVisible + visibleCount / 2 if (selectedIndex > middleIndex) { val maxFirstVisible = (navConfigs.size - visibleCount).coerceAtLeast(0) val targetFirstVisible = (selectedIndex - visibleCount / 2) .coerceIn(0, maxFirstVisible) if (targetFirstVisible != firstVisible) { listState.animateScrollToItem(index = targetFirstVisible) } } return@LaunchedEffect } // 3) 选中项不在可见区域:直接滚动到“中间位置”附近 val maxFirstVisible = (navConfigs.size - visibleCount).coerceAtLeast(0) val targetFirstVisible = (selectedIndex - visibleCount / 2) .coerceIn(0, maxFirstVisible) if (targetFirstVisible != firstVisible) { listState.animateScrollToItem(index = targetFirstVisible) } } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } TvAlertDialog( modifier = modifier, onDismissRequest = { saveUgcNavConfigs(navConfigs) onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_ugc_nav_items_title)) }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { Key.DirectionLeft -> { if (selectedIndex > 0) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex - 1] this[selectedIndex - 1] = temp } selectedIndex-- } true } Key.DirectionRight -> { if (selectedIndex < navConfigs.size - 1) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex + 1] this[selectedIndex + 1] = temp } selectedIndex++ } true } Key.DirectionUp -> { if (selectedIndex > 0) selectedIndex-- true } Key.DirectionDown -> { if (selectedIndex < navConfigs.size - 1) selectedIndex++ true } Key.Enter, Key.DirectionCenter -> { val config = navConfigs[selectedIndex] navConfigs = navConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = !config.hidden) } true } else -> false } } false } ) { Text( text = stringResource(R.string.settings_ui_ugc_nav_items_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState, verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = navConfigs, key = { index, config -> "$index-nav-${config.ordinal}" } ) { index, config -> val navItem = UgcTopNavItem.entries.getOrNull(config.ordinal) ?: return@itemsIndexed NavItemEditRow( title = navItem.getDisplayName(LocalContext.current), hidden = config.hidden, selected = index == selectedIndex, onFocus = { selectedIndex = index } ) } } } }, confirmButton = {} ) } @Composable private fun PgcNavItemsEditDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, initialOrderString: String ) { if (!show) return val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val initialConfigs = remember(initialOrderString) { parseNavItemsOrderToConfig(initialOrderString, PgcTopNavItem.entries.size) } var navConfigs by remember { mutableStateOf(initialConfigs) } var selectedIndex by remember { mutableIntStateOf(0) } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } TvAlertDialog( modifier = modifier, onDismissRequest = { savePgcNavConfigs(navConfigs) onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_pgc_nav_items_title)) }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { Key.DirectionLeft -> { if (selectedIndex > 0) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex - 1] this[selectedIndex - 1] = temp } selectedIndex-- } true } Key.DirectionRight -> { if (selectedIndex < navConfigs.size - 1) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex + 1] this[selectedIndex + 1] = temp } selectedIndex++ } true } Key.DirectionUp -> { if (selectedIndex > 0) selectedIndex-- true } Key.DirectionDown -> { if (selectedIndex < navConfigs.size - 1) selectedIndex++ true } Key.Enter, Key.DirectionCenter -> { val config = navConfigs[selectedIndex] navConfigs = navConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = !config.hidden) } true } else -> false } } false } ) { Text( text = stringResource(R.string.settings_ui_pgc_nav_items_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) navConfigs.forEachIndexed { index, config -> val navItem = PgcTopNavItem.entries.getOrNull(config.ordinal) ?: return@forEachIndexed NavItemEditRow( title = navItem.getDisplayName(LocalContext.current), hidden = config.hidden, selected = index == selectedIndex, onFocus = { selectedIndex = index } ) } } }, confirmButton = {} ) } @Composable private fun NavItemEditRow( title: String, hidden: Boolean, selected: Boolean, showDefaultTag: Boolean = false, onFocus: () -> Unit ) { EditableNavRow( selected = selected, onFocus = onFocus, headline = { Text( text = title, textDecoration = if (hidden) TextDecoration.LineThrough else null, color = if (selected) { MaterialTheme.colorScheme.background } else if (hidden) { MaterialTheme.colorScheme.onSurfaceVariant } else { MaterialTheme.colorScheme.onSurface } ) }, trailing = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (showDefaultTag) { Text( text = stringResource(R.string.settings_ui_home_nav_default_tag), style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.primary ) } // 隐藏状态标记 if (hidden) { Text( text = stringResource(R.string.settings_ui_home_nav_hidden), style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.error ) } else { Text( text = stringResource(R.string.settings_ui_home_nav_visible), style = MaterialTheme.typography.bodySmall, color = if (selected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurfaceVariant ) } } } ) } @Composable private fun EditableNavRow( selected: Boolean, onFocus: () -> Unit, headline: @Composable () -> Unit, trailing: @Composable () -> Unit ) { val shape = remember { RoundedCornerShape(12.dp) } val bgColor = if (selected) MaterialTheme.colorScheme.onBackground else Color.Transparent Row( modifier = Modifier .fillMaxWidth() .onFocusChanged { if (it.hasFocus) onFocus() } .background(color = bgColor, shape = shape) .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Box(modifier = Modifier.weight(1f)) { headline() } trailing() } } /** * 保存导航项配置到 Prefs * 默认标签强制不隐藏 */ private fun saveNavConfigs(navConfigs: List, defaultTabOrdinal: Int) { val finalOrderString = navConfigs.joinToString(",") { config -> val shouldHide = if (config.ordinal == defaultTabOrdinal) { false // 默认标签强制不隐藏 } else { config.hidden } if (shouldHide) "-${config.ordinal}" else "${config.ordinal}" } Prefs.homeNavItemsOrder = finalOrderString } private fun saveUgcNavConfigs(navConfigs: List) { val finalOrderString = navConfigs.joinToString(",") { config -> if (config.hidden) "-${config.ordinal}" else "${config.ordinal}" } Prefs.ugcNavItemsOrder = finalOrderString } private fun savePgcNavConfigs(navConfigs: List) { val finalOrderString = navConfigs.joinToString(",") { config -> if (config.hidden) "-${config.ordinal}" else "${config.ordinal}" } Prefs.pgcNavItemsOrder = finalOrderString } private fun saveLiveNavConfigs(navConfigs: List) { val finalOrderString = navConfigs.joinToString(",") { config -> if (config.hidden) "-${config.id}" else config.id } Prefs.liveNavItemsOrder = finalOrderString } @Composable private fun LiveNavItemsEditDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, initialOrderString: String ) { if (!show) return val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val cachedAreas = remember { parseCachedLiveAreaGroups(Prefs.cachedLiveAreaGroups) } val isLoggedIn = remember { Prefs.isLogin } val initialConfigs = remember(initialOrderString) { parseLiveNavItemsOrderToConfig(initialOrderString, cachedAreas, isLoggedIn) } var navConfigs by remember { mutableStateOf(initialConfigs) } var selectedIndex by remember { mutableIntStateOf(0) } val listState = rememberLazyListState() LaunchedEffect(selectedIndex, navConfigs.size) { if (navConfigs.isEmpty()) return@LaunchedEffect val layoutInfo = listState.layoutInfo val viewportStart = layoutInfo.viewportStartOffset val viewportEnd = layoutInfo.viewportEndOffset val viewportSize = viewportEnd - viewportStart if (viewportSize <= 0) return@LaunchedEffect val visibleItems = layoutInfo.visibleItemsInfo val visibleCount = visibleItems.size if (visibleCount <= 0) return@LaunchedEffect val firstVisible = listState.firstVisibleItemIndex val selectedItemInfo = visibleItems.firstOrNull { it.index == selectedIndex } if (selectedItemInfo != null) { val itemStart = selectedItemInfo.offset val itemEnd = itemStart + selectedItemInfo.size if (itemStart < viewportStart) { listState.animateScrollToItem(index = selectedIndex, scrollOffset = 0) return@LaunchedEffect } if (itemEnd > viewportEnd) { val bottomAlignedOffset = (viewportSize - selectedItemInfo.size).coerceAtLeast(0) listState.animateScrollToItem(index = selectedIndex, scrollOffset = bottomAlignedOffset) return@LaunchedEffect } val middleIndex = firstVisible + visibleCount / 2 if (selectedIndex > middleIndex) { val maxFirstVisible = (navConfigs.size - visibleCount).coerceAtLeast(0) val targetFirstVisible = (selectedIndex - visibleCount / 2) .coerceIn(0, maxFirstVisible) if (targetFirstVisible != firstVisible) { listState.animateScrollToItem(index = targetFirstVisible) } } return@LaunchedEffect } val maxFirstVisible = (navConfigs.size - visibleCount).coerceAtLeast(0) val targetFirstVisible = (selectedIndex - visibleCount / 2) .coerceIn(0, maxFirstVisible) if (targetFirstVisible != firstVisible) { listState.animateScrollToItem(index = targetFirstVisible) } } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } TvAlertDialog( modifier = modifier, onDismissRequest = { saveLiveNavConfigs(navConfigs) onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_live_nav_items_title)) }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { Key.DirectionLeft -> { if (selectedIndex > 0) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex - 1] this[selectedIndex - 1] = temp } selectedIndex-- } true } Key.DirectionRight -> { if (selectedIndex < navConfigs.size - 1) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex + 1] this[selectedIndex + 1] = temp } selectedIndex++ } true } Key.DirectionUp -> { if (selectedIndex > 0) selectedIndex-- true } Key.DirectionDown -> { if (selectedIndex < navConfigs.size - 1) selectedIndex++ true } Key.Enter, Key.DirectionCenter -> { val config = navConfigs[selectedIndex] navConfigs = navConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = !config.hidden) } true } else -> false } } false } ) { if (cachedAreas.isEmpty()) { Text( text = stringResource(R.string.settings_ui_live_nav_items_empty_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { Text( text = stringResource(R.string.settings_ui_live_nav_items_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Spacer(modifier = Modifier.height(8.dp)) LazyColumn( modifier = Modifier.fillMaxWidth(), state = listState, verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed( items = navConfigs, key = { index, config -> "$index-live-nav-${config.id}" } ) { index, config -> NavItemEditRow( title = getLiveNavItemDisplayName(config.id, cachedAreas), hidden = config.hidden, selected = index == selectedIndex, onFocus = { selectedIndex = index } ) } } } }, confirmButton = {} ) } @Composable private fun DrawerNavItemsEditDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, initialOrderString: String ) { if (!show) return val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val initialConfigs = remember(initialOrderString) { parseDrawerNavItemsOrderToConfig(initialOrderString) } var navConfigs by remember { mutableStateOf(initialConfigs) } var selectedIndex by remember { mutableIntStateOf(0) } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } TvAlertDialog( modifier = modifier, onDismissRequest = { saveDrawerNavConfigs(navConfigs) onHideDialog() }, title = { Text(text = stringResource(R.string.settings_ui_drawer_nav_items_title)) }, text = { Column( modifier = Modifier .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { if (it.type == KeyEventType.KeyDown) { when (it.key) { Key.DirectionLeft -> { if (selectedIndex > 0) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex - 1] this[selectedIndex - 1] = temp } selectedIndex-- } true } Key.DirectionRight -> { if (selectedIndex < navConfigs.size - 1) { navConfigs = navConfigs.toMutableList().apply { val temp = this[selectedIndex] this[selectedIndex] = this[selectedIndex + 1] this[selectedIndex + 1] = temp } selectedIndex++ } true } Key.DirectionUp -> { if (selectedIndex > 0) selectedIndex-- true } Key.DirectionDown -> { if (selectedIndex < navConfigs.size - 1) selectedIndex++ true } Key.Enter, Key.DirectionCenter -> { val config = navConfigs[selectedIndex] navConfigs = navConfigs.toMutableList().apply { this[selectedIndex] = config.copy(hidden = !config.hidden) } true } else -> false } } false } ) { Text( text = stringResource(R.string.settings_ui_drawer_nav_items_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) navConfigs.forEachIndexed { index, config -> val drawerItem = DrawerItem.entries.getOrNull(config.ordinal) ?: return@forEachIndexed NavItemEditRow( title = drawerItem.displayName, hidden = config.hidden, selected = index == selectedIndex, onFocus = { selectedIndex = index } ) } } }, confirmButton = {} ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/FavoriteScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import android.content.Context import android.view.KeyEvent import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState 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.height import androidx.compose.foundation.layout.offset 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.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.FavoriteFolderMetadata import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.NavSwitchMode import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.manager.VideoUserActionManager import dev.aaa1115910.bv.tv.component.TopNav import dev.aaa1115910.bv.tv.component.TopNavItem import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.tv.util.stableItemKey import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.onDelayFocusChanged import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.user.FavoriteViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable fun FavoriteScreen( modifier: Modifier = Modifier, favoriteViewModel: FavoriteViewModel = koinViewModel(), showPageTitle: Boolean = true ) { val context = LocalContext.current val scope = rememberCoroutineScope() val navSwitchMode by Prefs.navSwitchModeFlow.collectAsState(Prefs.navSwitchMode) var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < 4 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) val focusRequester = remember { FocusRequester() } val defaultFocusRequester = remember { FocusRequester() } val gridDefaultFocusRequester = remember { FocusRequester() } val gridFocusRestorer = rememberTvLazyListFocusRestorer(gridDefaultFocusRequester) var focusOnTabs by remember { mutableStateOf(true) } var focusOnGrid by remember { mutableStateOf(false) } val lazyGridState = rememberLazyGridState() val favoriteTopNavItems = favoriteViewModel.favoriteFolderMetadataList.map(::FavoriteFolderTopNavItem) var deleteMode by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var selectedVideo by remember { mutableStateOf(null) } var selectedIndex by remember { mutableIntStateOf(0) } val focusRequesters = remember { mutableMapOf() } fun getFocusRequester(index: Int): FocusRequester { return focusRequesters.getOrPut(index) { FocusRequester() } } val updateCurrentFavoriteFolder: (folderMetadata: FavoriteFolderMetadata) -> Unit = { folderMetadata -> favoriteViewModel.currentFavoriteFolderMetadata = folderMetadata favoriteViewModel.favorites.clear() favoriteViewModel.resetPageNumber() favoriteViewModel.updateFolderItems(force = true) } BackHandler( enabled = focusOnGrid && !deleteMode && !showPageTitle ) { scope.launch(Dispatchers.Main) { lazyGridState.scrollToItem(0) delay(100) focusOnGrid = false defaultFocusRequester.requestFocus() } } LaunchedEffect(Unit) { if (favoriteViewModel.favoriteFolderMetadataList.isEmpty()) { favoriteViewModel.clearData() favoriteViewModel.updateFoldersInfo() if (showPageTitle) { delay(100) defaultFocusRequester.requestFocus() } } } fun focusTopTabIfListEmpty() { if (favoriteViewModel.favorites.isEmpty()) { deleteMode = false focusOnGrid = false defaultFocusRequester.requestFocus(scope) } } Scaffold( modifier = modifier, topBar = { if (showPageTitle) { Box( modifier = Modifier.padding( start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp ) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.user_homepage_favorite), fontSize = titleFontSize.sp ) Column ( modifier = Modifier.weight(1f), ){ Text( modifier = Modifier.fillMaxWidth(), text = stringResource( R.string.load_data_count, favoriteViewModel.favorites.size ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontSize = 11.sp, textAlign = TextAlign.End, ) Text( modifier = Modifier.fillMaxWidth(), text = if (deleteMode) stringResource(R.string.delete_mode_action_hint) else stringResource(R.string.delete_mode_hint), color = if (deleteMode) Color.Red.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontSize = 11.sp, textAlign = TextAlign.End ) } } } } } ) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) ) { TopNav( modifier = Modifier .focusRequester(defaultFocusRequester) .onFocusChanged { focusOnTabs = it.hasFocus } .onDelayFocusChanged(50) { if (focusOnTabs) { focusRequester.requestFocus() } }, paddingTop = 0.dp, items = favoriteTopNavItems, useSmallSize = !showPageTitle, initialSelectedItem = favoriteTopNavItems.firstOrNull { it.folderMetadata == favoriteViewModel.currentFavoriteFolderMetadata }, navSwitchMode = navSwitchMode, tabFocusRequester = focusRequester, onSelectedChanged = { selectedItem -> val folderMetadata = (selectedItem as FavoriteFolderTopNavItem).folderMetadata if (favoriteViewModel.currentFavoriteFolderMetadata != folderMetadata) { updateCurrentFavoriteFolder(folderMetadata) } } ) if (!showPageTitle) { Text( modifier = Modifier .offset(y = (-6).dp) .fillMaxWidth() .height(14.dp), text = if (deleteMode) stringResource(R.string.delete_mode_action_hint) else stringResource(R.string.delete_mode_hint), color = if (deleteMode) Color.Red.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), fontSize = 11.sp, textAlign = TextAlign.End, lineHeight = 14.sp ) } ProvideListBringIntoViewSpec(padding = 24.dp) { LazyVerticalGrid( modifier = gridFocusRestorer.containerModifier( Modifier .weight(1f) .blockDownFocusExitAtGridEnd( currentIndex = currentIndex, itemCount = favoriteViewModel.favorites.size, columnCount = 4 ) .onPreviewKeyEvent { keyEvent -> if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP && (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_MENU || keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DEL) ) { deleteMode = !deleteMode return@onPreviewKeyEvent true } if (deleteMode && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BACK) { if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP) { deleteMode = false } return@onPreviewKeyEvent true } false } ), state = lazyGridState, columns = GridCells.Fixed(4), contentPadding = PaddingValues( top = if (showPageTitle) 20.dp else 0.dp, bottom = 20.dp, start = 20.dp, end = 20.dp ), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(13.dp) ) { itemsIndexed( items = favoriteViewModel.favorites, key = { _, history -> history.stableItemKey() } ) { index, history -> SmallVideoCard( modifier = gridFocusRestorer.firstItemModifier(index) .focusRequester(getFocusRequester(index)), data = history, onClick = { if (deleteMode) { selectedVideo = history selectedIndex = index showDeleteConfirmDialog = true } else { VideoInfoActivity.actionStart(context, history.avid) } }, onLongClick = { if (deleteMode) { val nextIndex = if (index < favoriteViewModel.favorites.size - 1) index + 1 else index - 1 if (nextIndex >= 0) runCatching { getFocusRequester(nextIndex).requestFocus() } val aid = history.avid val folderId = favoriteViewModel.currentFavoriteFolderMetadata?.id scope.launch { if (folderId != null) { val success = VideoUserActionManager.delVideoFromFavoriteFolder(aid = aid, folderId = folderId) if (success) { favoriteViewModel.removeFavoriteFromList(aid) focusTopTabIfListEmpty() context.getString(R.string.favorite_delete_success).toast(context) } else { context.getString(R.string.favorite_delete_failed).toast(context) } } } } else { UpInfoActivity.actionStart( context, mid = history.upId, name = history.upName, face = history.upFace ) } }, onFocus = { focusOnGrid = true currentIndex = index //预加载 if (index + 12 > favoriteViewModel.favorites.size) { favoriteViewModel.updateFolderItems() } } ) } if ( favoriteViewModel.favorites.isEmpty() && favoriteViewModel.currentFavoriteFolderMetadata != null && !favoriteViewModel.updatingFolders && !favoriteViewModel.updatingFolderItems ) { item(span = { GridItemSpan(4) }) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = stringResource(R.string.no_data), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } } } } } if (showDeleteConfirmDialog && selectedVideo != null) { DeleteFavoriteConfirmDialog( show = showDeleteConfirmDialog, videoTitle = selectedVideo!!.title, onConfirm = { val nextIndex = if (selectedIndex < favoriteViewModel.favorites.size - 1) selectedIndex + 1 else selectedIndex - 1 if (nextIndex >= 0) runCatching { getFocusRequester(nextIndex).requestFocus() } val aid = selectedVideo!!.avid val folderId = favoriteViewModel.currentFavoriteFolderMetadata?.id showDeleteConfirmDialog = false selectedVideo = null scope.launch { if (folderId != null) { val success = VideoUserActionManager.delVideoFromFavoriteFolder(aid = aid, folderId = folderId) if (success) { favoriteViewModel.removeFavoriteFromList(aid) focusTopTabIfListEmpty() context.getString(R.string.favorite_delete_success).toast(context) } else { context.getString(R.string.favorite_delete_failed).toast(context) } } } }, onDismiss = { showDeleteConfirmDialog = false scope.launch { runCatching { getFocusRequester(selectedIndex).requestFocus() } } selectedVideo = null } ) } } private data class FavoriteFolderTopNavItem( val folderMetadata: FavoriteFolderMetadata ) : TopNavItem { override fun getDisplayName(context: Context): String { return folderMetadata.title } } @Composable private fun DeleteFavoriteConfirmDialog( show: Boolean, videoTitle: String, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) focusRequester.requestFocus() } TvAlertDialog( onDismissRequest = onDismiss, title = { Text(text = stringResource(R.string.favorite_delete_confirm_dialog_title)) }, text = { Text( text = stringResource( R.string.favorite_delete_confirm_dialog_text, videoTitle ) ) }, confirmButton = { Button(onClick = onConfirm) { Text(text = stringResource(R.string.favorite_delete_confirm_dialog_confirm)) } }, dismissButton = { OutlinedButton( modifier = Modifier.focusRequester(focusRequester), onClick = onDismiss ) { Text(text = stringResource(R.string.favorite_delete_confirm_dialog_dismiss)) } } ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/FollowScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.offset 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction 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.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.LoadingTip import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.viewmodel.user.FollowViewModel import org.koin.androidx.compose.koinViewModel @Composable fun FollowScreen( modifier: Modifier = Modifier, followViewModel: FollowViewModel = koinViewModel() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val defaultFocusRequester = remember { FocusRequester() } val gridFocusRestorer = rememberTvLazyListFocusRestorer(defaultFocusRequester) var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < 3 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) val searchState: TextFieldState = rememberTextFieldState() // 根据搜索关键字筛选用户(用户名或签名包含关键字,不区分大小写) val filteredUsers by remember { derivedStateOf { val keyword = searchState.text.toString().trim() if (keyword.isEmpty()) followViewModel.followedUsers else { // 按字符匹配:关键字中的每个非空白字符都必须在用户名或签名中出现 val chars = keyword.filter { !it.isWhitespace() }.map { it.toString() } followViewModel.followedUsers.filter { up -> chars.all { ch -> up.name.contains(ch, ignoreCase = true) || up.sign.contains(ch, ignoreCase = true) } } } } } LaunchedEffect(followViewModel.updating) { if (!followViewModel.updating) { defaultFocusRequester.requestFocus(scope) } } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding( start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp ) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.user_homepage_follow), fontSize = titleFontSize.sp ) OutlinedTextField( state = searchState, enabled = !followViewModel.updating, modifier = Modifier .offset(y = (-2).dp) .width(258.dp) .height(36.dp), shape = MaterialTheme.shapes.small, contentPadding = PaddingValues( start = 0.dp, end = 8.dp, top = 4.dp, bottom = 4.dp ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), lineLimits = TextFieldLineLimits.SingleLine, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.inverseSurface, cursorColor = MaterialTheme.colorScheme.inverseSurface ), leadingIcon = (@Composable { Icon( imageVector = Icons.Filled.Search, contentDescription = null ) }) ) Text( modifier = Modifier.offset(y = 8.dp), text = stringResource( R.string.load_data_count_no_more, filteredUsers.size ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } ) { innerPadding -> LazyVerticalGrid( modifier = gridFocusRestorer.containerModifier(Modifier.padding(innerPadding)), columns = GridCells.Fixed(3), contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp), horizontalArrangement = Arrangement.spacedBy(20.dp) ) { if (!followViewModel.updating) { itemsIndexed( items = filteredUsers, key = { index, up -> "$index-up-${up.mid}" } ) { index, up -> val upCardModifier = gridFocusRestorer.firstItemModifier(index) UpCard( modifier = upCardModifier, face = up.avatar, sign = up.sign, username = up.name, onFocusChange = { if (it) currentIndex = index }, onClick = { UpInfoActivity.actionStart( context = context, mid = up.mid, name = up.name, face = up.avatar ) } ) } } else { item( span = { GridItemSpan(3) } ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { LoadingTip() } } } } } } @Composable fun UpCard( modifier: Modifier = Modifier, face: String, sign: String, username: String, onFocusChange: (hasFocus: Boolean) -> Unit, onClick: () -> Unit, onLongClick: () -> Unit = {} ) { Surface( modifier = modifier .onFocusChanged { onFocusChange(it.hasFocus) } .size(280.dp, 100.dp), colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surface, focusedContainerColor = MaterialTheme.colorScheme.surface, pressedContainerColor = MaterialTheme.colorScheme.surface ), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), border = ClickableSurfaceDefaults.border( focusedBorder = Border( border = BorderStroke(width = 3.dp, color = MaterialTheme.colorScheme.border), shape = MaterialTheme.shapes.medium ) ), onClick = onClick, onLongClick = onLongClick ) { Row( modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically ) { androidx.compose.material3.Surface( modifier = Modifier .padding(start = 12.dp, end = 8.dp) .size(48.dp) .clip(CircleShape), color = Color.White ) { AsyncImage( modifier = Modifier .size(48.dp) .clip(CircleShape), model = face, contentDescription = null, contentScale = ContentScale.FillBounds ) } Column { Text( text = username, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = sign, maxLines = 2, overflow = TextOverflow.Ellipsis ) } } } } @Preview @Composable fun UpCardPreview() { BVTheme { UpCard( face = "", sign = "一只业余做翻译的Klei迷,动态区UP(自称),缺氧官中反馈可私信", username = "username", onFocusChange = {}, onClick = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/FollowingSeasonFilter.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Done import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.FilterChip import androidx.tv.material3.Icon import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.season.FollowingSeasonStatus import dev.aaa1115910.biliapi.entity.season.FollowingSeasonType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.util.getDisplayName import dev.aaa1115910.bv.util.ifElse @Composable fun FollowingSeasonFilter( modifier: Modifier = Modifier, show: Boolean, onHideFilter: () -> Unit, selectedType: FollowingSeasonType, selectedStatus: FollowingSeasonStatus, onSelectedTypeChange: (FollowingSeasonType) -> Unit, onSelectedStatusChange: (FollowingSeasonStatus) -> Unit ) { val context = LocalContext.current val row1FocusRequester = remember { FocusRequester() } val row2FocusRequester = remember { FocusRequester() } val filterRowSpace = 8.dp if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = onHideFilter, title = { Text(text = stringResource(R.string.filter_dialog_title)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(filterRowSpace) ) { LazyRow( modifier = Modifier .focusRestorer(row1FocusRequester), horizontalArrangement = Arrangement.spacedBy(filterRowSpace), contentPadding = PaddingValues(horizontal = filterRowSpace) ) { itemsIndexed( items = FollowingSeasonType.entries, key = { index, type -> "$index-type-${type.name}" } ) { _, type -> FilterDialogFilterChip( modifier = Modifier .ifElse( type == selectedType, Modifier.focusRequester(row1FocusRequester) ), selected = type == selectedType, onClick = { onSelectedTypeChange(type) }, label = { Text(text = type.getDisplayName(context)) }, ) } } LazyRow( modifier = Modifier .focusRestorer(row2FocusRequester), horizontalArrangement = Arrangement.spacedBy(filterRowSpace), contentPadding = PaddingValues(horizontal = filterRowSpace) ) { itemsIndexed( items = FollowingSeasonStatus.entries, key = { index, status -> "$index-status-${status.name}" } ) { _, status -> FilterDialogFilterChip( modifier = Modifier .ifElse( status == selectedStatus, Modifier.focusRequester(row2FocusRequester) ), selected = status == selectedStatus, onClick = { onSelectedStatusChange(status) }, label = { Text(text = status.getDisplayName(context)) } ) } } } }, confirmButton = {} ) } BackHandler( enabled = show, onBack = onHideFilter ) } @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun FilterDialogFilterChip( modifier: Modifier = Modifier, selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit, ) { FilterChip( modifier = modifier, selected = selected, onClick = onClick, content = label, leadingIcon = { Row { AnimatedVisibility(visible = selected) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Rounded.Done, contentDescription = null ) } } } ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/FollowingSeasonScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import android.view.KeyEvent import androidx.compose.animation.core.animateFloatAsState 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.biliapi.entity.season.FollowingSeason import dev.aaa1115910.biliapi.entity.season.FollowingSeasonStatus import dev.aaa1115910.biliapi.entity.season.FollowingSeasonType import dev.aaa1115910.bv.R import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.videocard.SeasonCard import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.util.ImageSize import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.getDisplayName import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.resizedImageUrl import dev.aaa1115910.bv.viewmodel.user.FollowingSeasonViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable fun FollowingSeasonScreen( modifier: Modifier = Modifier, followingSeasonViewModel: FollowingSeasonViewModel = koinViewModel(), showPageTitle: Boolean = true, topTabFocusRequester: FocusRequester? = null ) { val context = LocalContext.current val logger = KotlinLogging.logger { } val scope = rememberCoroutineScope() val gridFocusRestorer = rememberTvLazyListFocusRestorer() var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < 6 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) val subtitleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 36f else 24f, label = "subtitle font size" ) var showFilter by remember { mutableStateOf(false) } var deleteMode by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var selectedSeason by remember { mutableStateOf(null) } var selectedIndex by remember { mutableIntStateOf(0) } var focusTopTabWhenListEmpty by remember { mutableStateOf(false) } val focusRequesters = remember { mutableMapOf() } fun getFocusRequester(index: Int): FocusRequester { return focusRequesters.getOrPut(index) { FocusRequester() } } val followingSeasons = followingSeasonViewModel.followingSeasons var followingSeasonType by remember { mutableStateOf(followingSeasonViewModel.followingSeasonType) } var followingSeasonStatus by remember { mutableStateOf(followingSeasonViewModel.followingSeasonStatus) } val noMore = followingSeasonViewModel.noMore val updateType: (FollowingSeasonType) -> Unit = { if (followingSeasonType != it) { followingSeasonType = it followingSeasonViewModel.followingSeasonType = it followingSeasonViewModel.clearData() followingSeasonViewModel.loadMore() } } val updateStatus: (FollowingSeasonStatus) -> Unit = { if (followingSeasonStatus != it) { followingSeasonStatus = it followingSeasonViewModel.followingSeasonStatus = it followingSeasonViewModel.clearData() followingSeasonViewModel.loadMore() } } val onLongClickSeason: (FollowingSeason, Int) -> Unit = { season, index -> if (deleteMode) { if (topTabFocusRequester != null) { focusTopTabWhenListEmpty = true } val nextIndex = if (index < followingSeasons.size - 1) index + 1 else index - 1 if (nextIndex >= 0) runCatching { getFocusRequester(nextIndex).requestFocus() } followingSeasonViewModel.unfollowSeason(seasonId = season.seasonId) } else { showFilter = true } } LaunchedEffect(Unit) { if (followingSeasons.isEmpty()) { logger.fInfo { "Start update search result because filter updated" } followingSeasonViewModel.clearData() followingSeasonViewModel.loadMore() } } LaunchedEffect(followingSeasonViewModel.deleting, followingSeasons.size, focusTopTabWhenListEmpty) { if (!focusTopTabWhenListEmpty || followingSeasonViewModel.deleting) return@LaunchedEffect focusTopTabWhenListEmpty = false if (followingSeasons.isEmpty()) { deleteMode = false topTabFocusRequester?.requestFocus(scope) } } Scaffold( modifier = modifier, topBar = { if (showPageTitle) { Box( modifier = Modifier.padding( start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp ) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Bottom ) { Text( text = stringResource(R.string.title_activity_following_season), fontSize = titleFontSize.sp ) Text( text = followingSeasonType.getDisplayName(context), fontSize = subtitleFontSize.sp ) Text( text = "(${followingSeasonStatus.getDisplayName(context)})", fontSize = subtitleFontSize.sp ) } Column( horizontalAlignment = Alignment.End, ) { Text( text = if (deleteMode) stringResource(R.string.delete_mode_action_hint) else stringResource(R.string.following_season_hint), color = if (deleteMode) Color.Red.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontSize = 11.sp ) if (noMore) { Text( text = stringResource( R.string.load_data_count_no_more, followingSeasonViewModel.followingSeasons.size ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontSize = 11.sp ) } else { Text( text = stringResource( R.string.load_data_count, followingSeasonViewModel.followingSeasons.size ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontSize = 11.sp ) } } } } } else { Row( modifier = Modifier.fillMaxWidth().padding(end = 24.dp), horizontalArrangement = Arrangement.End ) { Text( text = if (deleteMode) stringResource(R.string.delete_mode_action_hint) else stringResource(R.string.following_season_hint), color = if (deleteMode) Color.Red.copy(alpha = 0.8f) else androidx.tv.material3.MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontSize = 11.sp ) } } } ) { innerPadding -> ProvideListBringIntoViewSpec { LazyVerticalGrid( modifier = gridFocusRestorer.containerModifier( Modifier .padding(innerPadding) .blockDownFocusExitAtGridEnd( currentIndex = currentIndex, itemCount = followingSeasons.size, columnCount = 6 ) .onPreviewKeyEvent { keyEvent -> if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP && (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_MENU || keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DEL) ) { deleteMode = !deleteMode return@onPreviewKeyEvent true } if (deleteMode && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BACK) { if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP) { deleteMode = false } return@onPreviewKeyEvent true } false } ), columns = GridCells.Fixed(6), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp) ) { itemsIndexed( items = followingSeasons, key = { _, followingSeason -> "season-${followingSeason.seasonId}" } ) { index, followingSeason -> SeasonCard( modifier = gridFocusRestorer.firstItemModifier(index) .focusRequester(getFocusRequester(index)), data = SeasonCardData( seasonId = followingSeason.seasonId, title = followingSeason.title, cover = followingSeason.cover.resizedImageUrl(ImageSize.SeasonCoverThumbnail), rating = null ), onFocus = { currentIndex = index if (index + 12 > followingSeasons.size) { println("load more by focus") followingSeasonViewModel.loadMore() } }, onClick = { if (deleteMode) { selectedSeason = followingSeason selectedIndex = index showDeleteConfirmDialog = true } else { SeasonInfoActivity.actionStart( context = context, seasonId = followingSeason.seasonId, proxyArea = ProxyArea.checkProxyArea(followingSeason.title) ) } }, onLongClick = { onLongClickSeason(followingSeason, index) } ) } if (followingSeasons.isEmpty() && noMore) { item( span = { GridItemSpan(6) } ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text(text = stringResource(R.string.no_data)) OutlinedButton(onClick = { showFilter = true }) { Text(text = stringResource(R.string.filter_dialog_open_tip_click)) } } } } } } } } FollowingSeasonFilter( show = showFilter, onHideFilter = { showFilter = false }, selectedType = followingSeasonType, selectedStatus = followingSeasonStatus, onSelectedTypeChange = updateType, onSelectedStatusChange = updateStatus ) if (showDeleteConfirmDialog && selectedSeason != null) { DeleteFollowingSeasonConfirmDialog( show = showDeleteConfirmDialog, seasonTitle = selectedSeason!!.title, onConfirm = { if (topTabFocusRequester != null) { focusTopTabWhenListEmpty = true } val nextIndex = if (selectedIndex < followingSeasons.size - 1) selectedIndex + 1 else selectedIndex - 1 if (nextIndex >= 0) runCatching { getFocusRequester(nextIndex).requestFocus() } followingSeasonViewModel.unfollowSeason(seasonId = selectedSeason!!.seasonId) showDeleteConfirmDialog = false selectedSeason = null }, onDismiss = { showDeleteConfirmDialog = false scope.launch { runCatching { getFocusRequester(selectedIndex).requestFocus() } } selectedSeason = null } ) } } @Composable private fun DeleteFollowingSeasonConfirmDialog( show: Boolean, seasonTitle: String, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) focusRequester.requestFocus() } TvAlertDialog( onDismissRequest = onDismiss, title = { Text(text = stringResource(R.string.following_season_delete_confirm_dialog_title)) }, text = { Text( text = stringResource( R.string.following_season_delete_confirm_dialog_text, seasonTitle ) ) }, confirmButton = { Button(onClick = onConfirm) { Text(text = stringResource(R.string.following_season_delete_confirm_dialog_confirm)) } }, dismissButton = { OutlinedButton( modifier = Modifier.focusRequester(focusRequester), onClick = onDismiss ) { Text(text = stringResource(R.string.following_season_delete_confirm_dialog_dismiss)) } } ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/HistoryScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import android.view.KeyEvent import androidx.compose.animation.core.animateFloatAsState 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.tv.util.stableItemKey import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.viewmodel.user.HistoryViewModel import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun HistoryScreen( modifier: Modifier = Modifier, historyViewModel: HistoryViewModel = koinViewModel(), showPageTitle: Boolean = true, topTabFocusRequester: FocusRequester? = null ) { val context = LocalContext.current val scope = rememberCoroutineScope() val videoInfoRepository: VideoInfoRepository = koinInject() val listFocusRestorer = rememberTvLazyListFocusRestorer() val lazyGridState = rememberLazyGridState() var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < 4 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) var deleteMode by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showClearConfirmDialog by remember { mutableStateOf(false) } var selectedVideo by remember { mutableStateOf(null) } var selectedIndex by remember { mutableIntStateOf(0) } var focusTopTabWhenListEmpty by remember { mutableStateOf(false) } val focusRequesters = remember { mutableMapOf() } fun getFocusRequester(index: Int): FocusRequester { return focusRequesters.getOrPut(index) { FocusRequester() } } LaunchedEffect(Unit) { if (historyViewModel.histories.isEmpty()) { historyViewModel.clearData() historyViewModel.update() } } LaunchedEffect(historyViewModel.deleting, historyViewModel.histories.size, focusTopTabWhenListEmpty) { if (!focusTopTabWhenListEmpty || historyViewModel.deleting) return@LaunchedEffect focusTopTabWhenListEmpty = false if (historyViewModel.histories.isEmpty()) { deleteMode = false topTabFocusRequester?.requestFocus(scope) } } Scaffold( modifier = modifier, topBar = { if (showPageTitle) { Box( modifier = Modifier.padding( start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp ) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.user_homepage_recent), fontSize = titleFontSize.sp ) if (historyViewModel.noMore) { Text( text = stringResource( R.string.load_data_count_no_more, historyViewModel.histories.size ), color = Color.White.copy(alpha = 0.6f) ) } else { Text( text = stringResource( R.string.load_data_count, historyViewModel.histories.size ), color = Color.White.copy(alpha = 0.6f) ) } } } } } ) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { Text( modifier = Modifier.fillMaxWidth().offset(x = (-20).dp, y = (-2).dp), text = if (deleteMode) stringResource(R.string.delete_mode_action_hint) else stringResource(R.string.delete_mode_hint), color = if (deleteMode) Color.Red.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), fontSize = 11.sp, textAlign = TextAlign.End ) ProvideListBringIntoViewSpec(padding = 24.dp) { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier( Modifier .blockDownFocusExitAtGridEnd( currentIndex = currentIndex, itemCount = historyViewModel.histories.size, columnCount = 4 ) .onPreviewKeyEvent { keyEvent -> if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP && (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_MENU || keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DEL) ) { deleteMode = !deleteMode return@onPreviewKeyEvent true } if (deleteMode && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BACK) { if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP) { deleteMode = false } return@onPreviewKeyEvent true } false } ), columns = GridCells.Fixed(4), state = lazyGridState, contentPadding = PaddingValues( top = if (showPageTitle) 20.dp else 4.dp, bottom = 20.dp, start = 20.dp, end = 20.dp ), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(13.dp) ) { itemsIndexed( items = historyViewModel.histories, key = { _, history -> history.historyKid ?: history.hashCode() } ) { index, history -> Box( contentAlignment = Alignment.Center ) { SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index) .focusRequester(getFocusRequester(index)), data = history, onClick = { if (deleteMode) { selectedVideo = history selectedIndex = index showDeleteConfirmDialog = true } else { videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll(historyViewModel.histories) if (history.jumpToSeason) { SeasonInfoActivity.actionStart( context = context, epId = history.epId, seasonId = history.seasonId, proxyArea = ProxyArea.checkProxyArea(history.title) ) } else { VideoInfoActivity.actionStart( context = context, aid = history.avid, proxyArea = ProxyArea.checkProxyArea(history.title) ) } } }, onLongClick = { if (deleteMode) { selectedIndex = index showClearConfirmDialog = true } else { UpInfoActivity.actionStart( context, mid = history.upId, name = history.upName, face = history.upFace ) } }, onFocus = { currentIndex = index //预加载 if (index + 12 > historyViewModel.histories.size) { historyViewModel.update() } } ) } } if (historyViewModel.histories.isEmpty() && historyViewModel.noMore) { item(span = { GridItemSpan(4) }) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = stringResource(R.string.no_data), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } } } } } if (showDeleteConfirmDialog && selectedVideo != null) { DeleteHistoryConfirmDialog( show = showDeleteConfirmDialog, videoTitle = selectedVideo!!.title, onConfirm = { if (topTabFocusRequester != null) { focusTopTabWhenListEmpty = true } val nextIndex = if (selectedIndex < historyViewModel.histories.size - 1) selectedIndex + 1 else selectedIndex - 1 if (nextIndex >= 0) runCatching { getFocusRequester(nextIndex).requestFocus() } historyViewModel.deleteHistory( business = selectedVideo!!.historyBusiness, kid = selectedVideo!!.historyKid ) showDeleteConfirmDialog = false selectedVideo = null }, onDismiss = { showDeleteConfirmDialog = false scope.launch { runCatching { getFocusRequester(selectedIndex).requestFocus() } } selectedVideo = null } ) } if (showClearConfirmDialog) { ClearHistoryConfirmDialog( show = showClearConfirmDialog, onConfirm = { if (topTabFocusRequester != null) { focusTopTabWhenListEmpty = true } historyViewModel.clearHistory() deleteMode = false showClearConfirmDialog = false }, onDismiss = { showClearConfirmDialog = false scope.launch { runCatching { getFocusRequester(selectedIndex).requestFocus() } } } ) } } @Composable private fun DeleteHistoryConfirmDialog( show: Boolean, videoTitle: String, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) focusRequester.requestFocus() } TvAlertDialog( onDismissRequest = onDismiss, title = { Text(text = stringResource(R.string.history_delete_confirm_dialog_title)) }, text = { Text( text = stringResource( R.string.history_delete_confirm_dialog_text, videoTitle ) ) }, confirmButton = { Button(onClick = onConfirm) { Text(text = stringResource(R.string.history_delete_confirm_dialog_confirm)) } }, dismissButton = { OutlinedButton( modifier = Modifier.focusRequester(focusRequester), onClick = onDismiss ) { Text(text = stringResource(R.string.history_delete_confirm_dialog_dismiss)) } } ) } @Composable private fun ClearHistoryConfirmDialog( show: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val focusRequester = remember { FocusRequester() } var consumeInitialConfirmKeyUp by remember { mutableStateOf(false) } fun handleInitialConfirmKeyUp(keyEvent: androidx.compose.ui.input.key.KeyEvent): Boolean { if (!consumeInitialConfirmKeyUp) return false val nativeKeyEvent = keyEvent.nativeKeyEvent val isConfirmKey = nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_CENTER || nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER || nativeKeyEvent.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER if (nativeKeyEvent.action == KeyEvent.ACTION_UP && isConfirmKey) { consumeInitialConfirmKeyUp = false return true } return false } LaunchedEffect(show) { if (show) { consumeInitialConfirmKeyUp = true focusRequester.requestFocus() } } TvAlertDialog( onDismissRequest = onDismiss, title = { Text(text = stringResource(R.string.history_clear_confirm_dialog_title)) }, text = { Text(text = stringResource(R.string.history_clear_confirm_dialog_text)) }, confirmButton = { Button( modifier = Modifier.onPreviewKeyEvent { handleInitialConfirmKeyUp(it) }, onClick = onConfirm ) { Text(text = stringResource(R.string.history_delete_confirm_dialog_confirm)) } }, dismissButton = { OutlinedButton( modifier = Modifier .focusRequester(focusRequester) .onPreviewKeyEvent { handleInitialConfirmKeyUp(it) }, onClick = onDismiss ) { Text(text = stringResource(R.string.history_delete_confirm_dialog_dismiss)) } } ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/ToViewScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import android.view.KeyEvent import androidx.compose.animation.core.animateFloatAsState 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.UpInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.tv.util.stableItemKey import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.repository.VideoInfoRepository import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.viewmodel.user.ToViewViewModel import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @Composable fun ToViewScreen( modifier: Modifier = Modifier, toViewViewModel: ToViewViewModel = koinViewModel(), showPageTitle: Boolean = true, topTabFocusRequester: FocusRequester? = null ) { val context = LocalContext.current val scope = rememberCoroutineScope() val videoInfoRepository: VideoInfoRepository = koinInject() val listFocusRestorer = rememberTvLazyListFocusRestorer() val lazyGridState = rememberLazyGridState() var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < 4 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) var deleteMode by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showClearConfirmDialog by remember { mutableStateOf(false) } var selectedVideo by remember { mutableStateOf(null) } var selectedIndex by remember { mutableIntStateOf(0) } var focusTopTabWhenListEmpty by remember { mutableStateOf(false) } val focusRequesters = remember { mutableMapOf() } fun getFocusRequester(index: Int): FocusRequester { return focusRequesters.getOrPut(index) { FocusRequester() } } LaunchedEffect(Unit) { if (toViewViewModel.histories.isEmpty()) { toViewViewModel.clearData() toViewViewModel.update() } } LaunchedEffect(toViewViewModel.deleting, toViewViewModel.histories.size, focusTopTabWhenListEmpty) { if (!focusTopTabWhenListEmpty || toViewViewModel.deleting) return@LaunchedEffect focusTopTabWhenListEmpty = false if (toViewViewModel.histories.isEmpty()) { deleteMode = false topTabFocusRequester?.requestFocus(scope) } } Scaffold( modifier = modifier, topBar = { if (showPageTitle) { Box( modifier = Modifier.padding( start = 48.dp, top = 24.dp, bottom = 8.dp, end = 48.dp ) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.title_activity_toview), fontSize = titleFontSize.sp ) if (toViewViewModel.noMore) { Text( text = stringResource( R.string.load_data_count_no_more, toViewViewModel.histories.size ), color = Color.White.copy(alpha = 0.6f) ) } else { Text( text = stringResource( R.string.load_data_count, toViewViewModel.histories.size ), color = Color.White.copy(alpha = 0.6f) ) } } } } } ) { innerPadding -> Column(modifier = Modifier.padding(innerPadding)) { Text( modifier = Modifier.fillMaxWidth().offset(x = (-20).dp, y = (-2).dp), text = if (deleteMode) stringResource(R.string.delete_mode_action_hint) else stringResource(R.string.delete_mode_hint), color = if (deleteMode) Color.Red.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), fontSize = 11.sp, textAlign = TextAlign.End ) ProvideListBringIntoViewSpec(padding = 24.dp) { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier( Modifier.blockDownFocusExitAtGridEnd( currentIndex = currentIndex, itemCount = toViewViewModel.histories.size, columnCount = 4 ) .onPreviewKeyEvent { keyEvent -> if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP && (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_MENU || keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DEL) ) { deleteMode = !deleteMode return@onPreviewKeyEvent true } if (deleteMode && keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_BACK) { if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP) { deleteMode = false } return@onPreviewKeyEvent true } false } ), columns = GridCells.Fixed(4), state = lazyGridState, contentPadding = PaddingValues( top = if (showPageTitle) 20.dp else 4.dp, bottom = 20.dp, start = 20.dp, end = 20.dp ), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(13.dp) ) { itemsIndexed( items = toViewViewModel.histories, key = { _, item -> item.avid } ) { index, item -> Box( contentAlignment = Alignment.Center ) { SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index) .focusRequester(getFocusRequester(index)), data = item, onClick = { if (deleteMode) { selectedVideo = item selectedIndex = index showDeleteConfirmDialog = true } else { videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll(toViewViewModel.histories) VideoInfoActivity.actionStart( context = context, aid = item.avid, proxyArea = ProxyArea.checkProxyArea(item.title) ) } }, onLongClick = { if (deleteMode) { selectedIndex = index showClearConfirmDialog = true } else { UpInfoActivity.actionStart( context, mid = item.upId, name = item.upName, face = item.upFace ) } }, onFocus = { currentIndex = index //预加载 // if (index + 12 > toViewViewModel.histories.size) { // toViewViewModel.update() // } } ) } } if (toViewViewModel.histories.isEmpty() && toViewViewModel.noMore) { item(span = { GridItemSpan(4) }) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = stringResource(R.string.no_data), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } } } } } if (showDeleteConfirmDialog && selectedVideo != null) { DeleteToViewConfirmDialog( show = showDeleteConfirmDialog, videoTitle = selectedVideo!!.title, onConfirm = { if (topTabFocusRequester != null) { focusTopTabWhenListEmpty = true } val nextIndex = if (selectedIndex < toViewViewModel.histories.size - 1) selectedIndex + 1 else selectedIndex - 1 if (nextIndex >= 0) runCatching { getFocusRequester(nextIndex).requestFocus() } toViewViewModel.deleteToView( avid = selectedVideo!!.avid ) showDeleteConfirmDialog = false selectedVideo = null }, onDismiss = { showDeleteConfirmDialog = false scope.launch { runCatching { getFocusRequester(selectedIndex).requestFocus() } } selectedVideo = null } ) } if (showClearConfirmDialog) { ClearToViewConfirmDialog( show = showClearConfirmDialog, onConfirm = { if (topTabFocusRequester != null) { focusTopTabWhenListEmpty = true } toViewViewModel.clearToView() deleteMode = false showClearConfirmDialog = false }, onDismiss = { showClearConfirmDialog = false scope.launch { runCatching { getFocusRequester(selectedIndex).requestFocus() } } } ) } } @Composable private fun DeleteToViewConfirmDialog( show: Boolean, videoTitle: String, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) focusRequester.requestFocus() } TvAlertDialog( onDismissRequest = onDismiss, title = { Text(text = stringResource(R.string.toview_delete_confirm_dialog_title)) }, text = { Text( text = stringResource( R.string.toview_delete_confirm_dialog_text, videoTitle ) ) }, confirmButton = { Button(onClick = onConfirm) { Text(text = stringResource(R.string.toview_delete_confirm_dialog_confirm)) } }, dismissButton = { OutlinedButton( modifier = Modifier.focusRequester(focusRequester), onClick = onDismiss ) { Text(text = stringResource(R.string.toview_delete_confirm_dialog_dismiss)) } } ) } @Composable private fun ClearToViewConfirmDialog( show: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit ) { val focusRequester = remember { FocusRequester() } var consumeInitialConfirmKeyUp by remember { mutableStateOf(false) } fun handleInitialConfirmKeyUp(keyEvent: androidx.compose.ui.input.key.KeyEvent): Boolean { if (!consumeInitialConfirmKeyUp) return false val nativeKeyEvent = keyEvent.nativeKeyEvent val isConfirmKey = nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_CENTER || nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER || nativeKeyEvent.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER if (nativeKeyEvent.action == KeyEvent.ACTION_UP && isConfirmKey) { consumeInitialConfirmKeyUp = false return true } return false } LaunchedEffect(show) { if (show) { consumeInitialConfirmKeyUp = true focusRequester.requestFocus() } } TvAlertDialog( onDismissRequest = onDismiss, title = { Text(text = stringResource(R.string.toview_clear_confirm_dialog_title)) }, text = { Text(text = stringResource(R.string.toview_clear_confirm_dialog_text)) }, confirmButton = { Button( modifier = Modifier.onPreviewKeyEvent { handleInitialConfirmKeyUp(it) }, onClick = onConfirm ) { Text(text = stringResource(R.string.toview_delete_confirm_dialog_confirm)) } }, dismissButton = { OutlinedButton( modifier = Modifier .focusRequester(focusRequester) .onPreviewKeyEvent { handleInitialConfirmKeyUp(it) }, onClick = onDismiss ) { Text(text = stringResource(R.string.toview_delete_confirm_dialog_dismiss)) } } ) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/UpInfoScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import android.app.Activity import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Done import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.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.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type 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 androidx.compose.ui.unit.sp import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.repositories.UserRepository import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.videocard.SmallVideoCard import dev.aaa1115910.bv.tv.manager.FollowStateManager import dev.aaa1115910.bv.tv.util.blockDownFocusExitAtGridEnd import dev.aaa1115910.bv.tv.util.ProvideListBringIntoViewSpec import dev.aaa1115910.bv.tv.util.rememberTvLazyListFocusRestorer import dev.aaa1115910.bv.tv.util.stableItemKey import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.user.UserSpaceViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import dev.aaa1115910.bv.repository.VideoInfoRepository import org.koin.androidx.compose.koinViewModel import org.koin.compose.getKoin import org.koin.compose.koinInject @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun UpSpaceScreen( modifier: Modifier = Modifier, userSpaceViewModel: UserSpaceViewModel = koinViewModel(), userRepository: UserRepository = getKoin().get(), ) { val context = LocalContext.current val videoInfoRepository: VideoInfoRepository = koinInject() val scope = rememberCoroutineScope() val logger = KotlinLogging.logger { } var currentIndex by remember { mutableIntStateOf(0) } val showLargeTitle by remember { derivedStateOf { currentIndex < 4 } } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 40f else 24f, label = "title font size" ) val infoFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 15f else 12f, label = "info font size" ) var showFollowButton by remember { mutableStateOf(false) } var isFollowing by remember { mutableStateOf(false) } var isLongPress by remember { mutableStateOf(false) } // 监听关注状态变化 val followStateMap by FollowStateManager.followStateMap.collectAsState() // 当关注状态map变化时,更新当前用户的关注状态 LaunchedEffect(followStateMap, userSpaceViewModel.upMid) { if (userSpaceViewModel.upMid > 0) { FollowStateManager.getFollowState(userSpaceViewModel.upMid)?.let { following -> isFollowing = following } } } val listFocusRequester = remember { FocusRequester() } val listFocusRestorer = rememberTvLazyListFocusRestorer(listFocusRequester) LaunchedEffect(userSpaceViewModel.tvSpaceVideos.isNotEmpty()) { listFocusRequester.requestFocus() } val addFollow: (afterModify: (success: Boolean) -> Unit) -> Unit = { afterModify -> scope.launch(Dispatchers.IO) { val userMid = userSpaceViewModel.upMid logger.fInfo { "Add follow to user $userMid" } val success = userRepository.followUser( mid = userMid, preferApiType = Prefs.apiType ) logger.fInfo { "Add follow result: $success" } // 更新缓存状态 if (success) { FollowStateManager.updateFollowState(userMid, true) } afterModify(success) } } val delFollow: (afterModify: (success: Boolean) -> Unit) -> Unit = { afterModify -> scope.launch(Dispatchers.IO) { val userMid = userSpaceViewModel.upMid logger.fInfo { "Del follow to user $userMid" } val success = userRepository.unfollowUser( mid = userMid, preferApiType = Prefs.apiType ) logger.fInfo { "Del follow result: $success" } // 更新缓存状态 if (success) { FollowStateManager.updateFollowState(userMid, false) } afterModify(success) } } LaunchedEffect(Unit) { val intent = (context as Activity).intent if (intent.hasExtra("mid")) { val mid = intent.getLongExtra("mid", 0) val name = intent.getStringExtra("name") ?: "" val face = intent.getStringExtra("face") ?: "" userSpaceViewModel.upMid = mid userSpaceViewModel.upName = name userSpaceViewModel.upFace = face scope.launch(Dispatchers.IO) { runCatching { val userInfo = userRepository.getUserCardInfo( mid = mid ) withContext(Dispatchers.Main) { userSpaceViewModel.upName = userInfo.card.name userSpaceViewModel.upFace = userInfo.card.face userSpaceViewModel.sign = userInfo.card.sign.replace("\n", "") userSpaceViewModel.fans = userInfo.card.fans userSpaceViewModel.friend = userInfo.card.attention if (Prefs.isLogin) { FollowStateManager.updateFollowState(mid, userInfo.following) isFollowing = userInfo.following showFollowButton = true } } }.onFailure { logger.fInfo { "Get user info failed: ${it.stackTraceToString()}" } } } userSpaceViewModel.update() } else { context.finish() } } Scaffold( modifier = modifier .onPreviewKeyEvent { val isDpadCenter = listOf(Key.Enter, Key.DirectionCenter).contains(it.key) if (isDpadCenter && it.type == KeyEventType.KeyDown) { isLongPress = it.nativeKeyEvent.repeatCount > 0 } false }, topBar = { Row( modifier = Modifier .fillMaxWidth() .padding(start = 24.dp, top = 12.dp, bottom = 12.dp, end = 24.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(24.dp) ) { Column( modifier = Modifier.weight(1f) ) { Row( verticalAlignment = Alignment.CenterVertically ) { if (userSpaceViewModel.upFace.isNotBlank()) { val imageUrl = userSpaceViewModel.upFace + "@128w_128h_1c_1s.webp" AsyncImage( modifier = Modifier .size(titleFontSize.dp) .clip(CircleShape), model = imageUrl, contentDescription = null, contentScale = ContentScale.Crop, onError = { error -> userSpaceViewModel.upFace = "" println("Failed to load avatar: $imageUrl") println("Error message: ${error.result.throwable}") }, onSuccess = { println("Avatar loaded successfully: $imageUrl") } ) Spacer(modifier = Modifier.width(8.dp)) } Text( text = userSpaceViewModel.upName, fontSize = titleFontSize.sp ) // 关注按钮 if (showFollowButton) { Surface( modifier = Modifier .padding(start = if (showLargeTitle) 24.dp else 4.dp, top = 2.dp) .scale(if (showLargeTitle) 1f else 0.7f), onClick = { if (isFollowing) { delFollow { success -> scope.launch(Dispatchers.Main) { if (success) { "已取消关注".toast(context) } else { "取消关注失败".toast(context) } } } } else { addFollow { success -> scope.launch(Dispatchers.Main) { if (success) { "关注成功".toast(context) } else { "关注失败".toast(context) } } } } }, colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.onSurface.copy( alpha = 0.2f ), focusedContainerColor = MaterialTheme.colorScheme.onSurface.copy( alpha = 0.2f ), pressedContainerColor = MaterialTheme.colorScheme.onSurface.copy( alpha = 0.3f ) ), shape = ClickableSurfaceDefaults.shape( shape = MaterialTheme.shapes.small ), border = ClickableSurfaceDefaults.border( focusedBorder = Border( border = BorderStroke( width = 2.dp, color = MaterialTheme.colorScheme.onSurface.copy( alpha = 0.3f ) ), shape = MaterialTheme.shapes.small ) ) ) { Row( modifier = Modifier.padding( horizontal = 10.dp, vertical = 5.dp ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { if (isFollowing) { Icon( imageVector = Icons.Rounded.Done, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(18.dp) ) Text( text = stringResource(R.string.video_info_followed), color = MaterialTheme.colorScheme.onSurface, fontSize = 15.sp ) } else { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(18.dp) ) Text( text = stringResource(R.string.video_info_follow), color = MaterialTheme.colorScheme.onSurface, fontSize = 15.sp ) } } } } } Row { Text( modifier = Modifier.padding(top = if (showLargeTitle) 6.dp else 4.dp), text = stringResource( R.string.friend_count, if (userSpaceViewModel.friend >= 10000) String.format( "%.2f", userSpaceViewModel.friend / 10000.0 ) + " 万" else userSpaceViewModel.friend.toString() ) + " · " + stringResource( R.string.fans_count, if (userSpaceViewModel.fans >= 10000) String.format( "%.2f", userSpaceViewModel.fans / 10000.0 ) + " 万" else userSpaceViewModel.fans.toString() ) + "${if (userSpaceViewModel.sign.isNotEmpty()) " | " + userSpaceViewModel.sign else ""}", color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), fontSize = infoFontSize.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, lineHeight = (infoFontSize * 1.4).sp ) } } Column { Text( text = stringResource( R.string.load_data_count, userSpaceViewModel.tvSpaceVideos.size ), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) AnimatedVisibility(visible = userSpaceViewModel.noMore) { Text( text = stringResource(R.string.load_data_no_more), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } } ) { innerPadding -> ProvideListBringIntoViewSpec(padding = 26.dp) { LazyVerticalGrid( modifier = listFocusRestorer.containerModifier( Modifier .padding(innerPadding) .blockDownFocusExitAtGridEnd( currentIndex = currentIndex, itemCount = userSpaceViewModel.tvSpaceVideos.size, columnCount = 4 ) ), columns = GridCells.Fixed(4), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp) ) { itemsIndexed( items = userSpaceViewModel.tvSpaceVideos, key = { index, video -> "$index-${video.stableItemKey()}" } ) { index, video -> SmallVideoCard( modifier = listFocusRestorer.firstItemModifier(index), data = video, onClick = { if (!isLongPress) { videoInfoRepository.preloadedVideoList.clear() videoInfoRepository.preloadedVideoList.addAll(userSpaceViewModel.tvSpaceVideos) VideoInfoActivity.actionStart( context = context, aid = video.avid, proxyArea = ProxyArea.checkProxyArea(video.title) ) } }, onFocus = { currentIndex = index if (index + 12 > userSpaceViewModel.tvSpaceVideos.size) { userSpaceViewModel.update() } } ) } } } } } //https://i2.hdslb.com/bfs/face/ea9b2fd60b04b123d0b48477838f60532b6271cd.jpg @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun UpFacePreview() { BVTheme { AsyncImage( modifier = Modifier .size(80.dp) .clip(CircleShape), model = "https://i2.hdslb.com/bfs/face/ea9b2fd60b04b123d0b48477838f60532b6271cd.jpg@80h_80w_1c.webp", contentDescription = null, contentScale = ContentScale.Crop ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/UserInfoScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import android.app.Activity import android.content.Intent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.tween 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.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.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection 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.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.biliapi.entity.season.FollowingSeasonStatus import dev.aaa1115910.biliapi.entity.season.FollowingSeasonType import dev.aaa1115910.biliapi.http.entity.AuthFailureException import dev.aaa1115910.biliapi.repositories.FavoriteRepository import dev.aaa1115910.biliapi.repositories.HistoryRepository import dev.aaa1115910.biliapi.repositories.SeasonRepository import dev.aaa1115910.biliapi.repositories.UserRepository import dev.aaa1115910.bv.BuildConfig import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.carddata.SeasonCardData import dev.aaa1115910.bv.entity.carddata.VideoCardData import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.user.FavoriteActivity import dev.aaa1115910.bv.tv.activities.user.FollowActivity import dev.aaa1115910.bv.tv.activities.user.FollowingSeasonActivity import dev.aaa1115910.bv.tv.activities.user.HistoryActivity import dev.aaa1115910.bv.tv.activities.user.UserSwitchActivity import dev.aaa1115910.bv.tv.activities.video.SeasonInfoActivity import dev.aaa1115910.bv.tv.activities.video.VideoInfoActivity import dev.aaa1115910.bv.tv.component.videocard.SeasonCard import dev.aaa1115910.bv.tv.component.videocard.VideosRow import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.Prefs import dev.aaa1115910.bv.util.fException import dev.aaa1115910.bv.util.fInfo import dev.aaa1115910.bv.util.fWarn import dev.aaa1115910.bv.util.formatHourMinSec import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.UserViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel import org.koin.compose.getKoin @Composable fun UserInfoScreen( modifier: Modifier = Modifier, userViewModel: UserViewModel = koinViewModel(), userRepository: UserRepository = getKoin().get(), favoriteRepository: FavoriteRepository = getKoin().get(), seasonRepository: SeasonRepository = getKoin().get(), historyRepository: HistoryRepository = getKoin().get(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() val lifecycleOwner = LocalLifecycleOwner.current val logger = KotlinLogging.logger { } val focusRequester = remember { FocusRequester() } var showLargeTitle by remember { mutableStateOf(true) } val titleFontSize by animateFloatAsState( targetValue = if (showLargeTitle) 48f else 24f, label = "title font size" ) val randomTitleList = context.resources.getStringArray(R.array.user_homepage_random_title) val title by remember { mutableStateOf(randomTitleList.random()) } var followingUpCount by remember { mutableIntStateOf(0) } val histories = remember { mutableStateListOf() } val animes = remember { mutableStateListOf() } val favorites = remember { mutableStateListOf() } val updateHistories = { scope.launch(Dispatchers.IO) { runCatching { val data = historyRepository.getHistories( cursor = 0, preferApiType = Prefs.apiType ) histories.clear() data.data.forEach { historyItem -> histories.add( VideoCardData( avid = historyItem.oid, title = historyItem.title, cover = historyItem.cover, upName = historyItem.author, timeString = if (historyItem.progress == -1) context.getString(R.string.play_time_finish) else context.getString( R.string.play_time_history, (historyItem.progress * 1000L).formatHourMinSec(), (historyItem.duration * 1000L).formatHourMinSec() ) ) ) } }.onFailure { logger.fWarn { "Load recent videos failed: ${it.stackTraceToString()}" } when (it) { is AuthFailureException -> { withContext(Dispatchers.Main) { context.getString(R.string.exception_auth_failure).toast(context) } logger.fInfo { "User auth failure" } if (!BuildConfig.DEBUG) userViewModel.logout() } else -> {} } } } } val updateFollowedAnimes = { scope.launch(Dispatchers.IO) { runCatching { val followingSeasonData = seasonRepository.getFollowingSeasons( type = FollowingSeasonType.Bangumi, status = FollowingSeasonStatus.All, pageNumber = 1, pageSize = 15, preferApiType = Prefs.apiType ) animes.clear() followingSeasonData.list.forEach { followedSeason -> animes.add( SeasonCardData( seasonId = followedSeason.seasonId, title = followedSeason.title, cover = followedSeason.cover, rating = null ) ) } }.onFailure { logger.fWarn { "Load followed animes failed: ${it.stackTraceToString()}" } when (it) { is AuthFailureException -> { withContext(Dispatchers.Main) { context.getString(R.string.exception_auth_failure).toast(context) } logger.fInfo { "User auth failure" } if (!BuildConfig.DEBUG) userViewModel.logout() } else -> {} } } } } val updateFavoriteVideos = { scope.launch(Dispatchers.IO) { var defaultFolderId: Long = 0 runCatching { val favoriteFolderMetadataList = favoriteRepository.getAllFavoriteFolderMetadataList( mid = Prefs.uid, preferApiType = Prefs.apiType ) if (favoriteFolderMetadataList.isEmpty()) { "未找到收藏夹".toast(context) return@launch } defaultFolderId = favoriteFolderMetadataList.find { it.title == "默认收藏夹" }?.id ?: 0 logger.fInfo { "Get favorite folders: ${favoriteFolderMetadataList.map { it.id }}" } }.onFailure { logger.fException(it) { "Load favorite folders failed" } } runCatching { val favoriteItems = favoriteRepository.getFavoriteFolderData( mediaId = defaultFolderId, preferApiType = Prefs.apiType ).medias favorites.clear() favoriteItems.forEach { favoriteItem -> favorites.add( VideoCardData( avid = favoriteItem.id, title = favoriteItem.title, cover = favoriteItem.cover, upName = favoriteItem.upper.name, time = favoriteItem.duration.toLong() * 1000 ) ) } }.onFailure { logger.fWarn { "Load favorite items failed: ${it.stackTraceToString()}" } when (it) { is AuthFailureException -> { withContext(Dispatchers.Main) { context.getString(R.string.exception_auth_failure).toast(context) } logger.fInfo { "User auth failure" } if (!BuildConfig.DEBUG) userViewModel.logout() } else -> {} } } } } val updateFollowingUpCount = { scope.launch(Dispatchers.IO) { logger.fInfo { "Update following up count with user ${Prefs.uid}" } followingUpCount = userRepository.getFollowingUpCount( mid = Prefs.uid, preferApiType = Prefs.apiType ) logger.fInfo { "Following up count: $followingUpCount" } } } val updateData = { if (!userViewModel.isLogin) { (context as? Activity)?.finish() } else { userViewModel.updateUserInfo(forceUpdate = true) updateHistories() updateFollowedAnimes() updateFavoriteVideos() updateFollowingUpCount() } } LaunchedEffect(Unit) { focusRequester.requestFocus() updateData() } 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) updateData() leaveFromThisPage = false } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } Scaffold( modifier = modifier, topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp) ) { Text( text = title, fontSize = titleFontSize.sp ) } } ) { innerPadding -> LazyColumn( modifier = Modifier.padding(innerPadding), contentPadding = PaddingValues(bottom = 24.dp) ) { item { UserRow( modifier = Modifier .focusRequester(focusRequester) .onFocusChanged { showLargeTitle = it.hasFocus }, username = userViewModel.username, face = userViewModel.face, uid = userViewModel.responseData?.mid ?: 0, level = userViewModel.responseData?.level ?: 0, currentExp = userViewModel.responseData?.levelExp?.currentExp ?: 0, nextLevelExp = with(userViewModel.responseData?.levelExp?.nextExp) { if (this == null) { 1 } else if (this <= 0) { userViewModel.responseData?.levelExp?.currentExp ?: 1 } else { (userViewModel.responseData?.levelExp?.currentExp ?: 1) +(userViewModel.responseData?.levelExp?.nextExp ?: 0) } }, showLabel = userViewModel.responseData?.vip?.avatarSubscript == 1, labelUrl = userViewModel.responseData?.vip?.label?.imgLabelUriHansStatic ?: "", followingUpCount = followingUpCount, onOpenFollowingUser = { context.startActivity(Intent(context, FollowActivity::class.java)) }, onOpenUserSwitch = { context.startActivity(Intent(context, UserSwitchActivity::class.java)) }, coins = userViewModel.responseData?.coins ?: 0f ) } item { RecentVideosRow( videos = histories, showMore = { context.startActivity(Intent(context, HistoryActivity::class.java)) } ) } item { FollowingAnimeVideosRow( videos = animes, showMore = { context.startActivity(Intent(context, FollowingSeasonActivity::class.java)) } ) } item { FavoriteVideosRow( videos = favorites, showMore = { context.startActivity(Intent(context, FavoriteActivity::class.java)) } ) } } } } @Composable private fun UserInfo( modifier: Modifier = Modifier, face: String, username: String, uid: Long, level: Int, currentExp: Int, nextLevelExp: Int, showLabel: Boolean, labelUrl: String, onClick: () -> Unit, coins: Float = 0f ) { var hasFocus by remember { mutableStateOf(false) } val levelSlider by animateFloatAsState( targetValue = currentExp.toFloat() / nextLevelExp, animationSpec = tween( durationMillis = 1500, easing = LinearEasing ), label = "Loading level exp slider" ) Surface( modifier = modifier .size(480.dp, 140.dp) .onFocusChanged { hasFocus = it.hasFocus }, colors = ClickableSurfaceDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = onClick ) { Row( verticalAlignment = Alignment.CenterVertically ) { androidx.compose.material3.Surface( modifier = Modifier .padding(start = 24.dp, end = 8.dp) .size(80.dp) .clip(CircleShape), color = Color.White ) { AsyncImage( modifier = Modifier .size(80.dp) .clip(CircleShape), model = face, contentDescription = null, contentScale = ContentScale.FillBounds ) } Column( modifier = Modifier .padding( start = 6.dp, top = 24.dp, end = 24.dp, bottom = 24.dp ) .height(80.dp), verticalArrangement = Arrangement.SpaceBetween ) { val startPaddingValue = 6.dp CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { Row( modifier = Modifier.padding(end = startPaddingValue), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically ) { if (showLabel) AsyncImage( //大会员 Tag 给定指定大小范围,避免加载时大小会突然变得非常大导致画面闪烁 modifier = Modifier .height(22.dp) .widthIn(max = 96.dp), model = labelUrl, contentDescription = null, contentScale = ContentScale.FillHeight ) Text( text = username, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Row( modifier = Modifier.padding(start = startPaddingValue), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Bottom ) { Text(text = stringResource(R.string.user_info_level, level)) Text(text = stringResource(R.string.user_info_coins, coins)) Text(text = stringResource(R.string.user_info_uid, uid)) } val sliderColor = if (hasFocus) { SliderDefaults.colors( disabledThumbColor = Color.Transparent, disabledActiveTrackColor = MaterialTheme.colorScheme.inverseOnSurface, disabledInactiveTrackColor = MaterialTheme.colorScheme.inverseOnSurface.copy( alpha = 0.3f ) ) } else { SliderDefaults.colors( disabledThumbColor = Color.Transparent, disabledActiveTrackColor = MaterialTheme.colorScheme.primary, ) } LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .padding(8.dp), progress = { levelSlider }, gapSize = 0.dp, ) } } } } @Composable private fun IncognitoModeCard( modifier: Modifier = Modifier ) { var enabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { enabled = Prefs.incognitoMode } IncognitoModeCardContent( modifier = modifier, enabled = enabled, onClick = { enabled = !enabled Prefs.incognitoMode = enabled } ) } @Composable private fun IncognitoModeCardContent( modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit ) { Surface( modifier = modifier.height(140.dp), colors = ClickableSurfaceDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = onClick ) { Column( modifier = Modifier .fillMaxHeight() .padding(horizontal = 24.dp, vertical = 24.dp), verticalArrangement = Arrangement.SpaceAround, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.user_info_Incognito_mode_title), style = MaterialTheme.typography.titleLarge ) Text( text = if (enabled) "\uD83D\uDC7B" + stringResource(R.string.user_info_Incognito_mode_on) else stringResource(R.string.user_info_Incognito_mode_off), style = MaterialTheme.typography.titleMedium ) } } } @Composable private fun FollowedUserCard( modifier: Modifier = Modifier, size: Int, onClick: () -> Unit ) { Surface( modifier = modifier.height(140.dp), colors = ClickableSurfaceDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = onClick ) { Column( modifier = Modifier .fillMaxHeight() .padding(horizontal = 24.dp, vertical = 24.dp), verticalArrangement = Arrangement.SpaceAround, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.user_homepage_follow), style = MaterialTheme.typography.titleLarge ) Text( text = "$size", style = MaterialTheme.typography.titleMedium ) } } } @Composable private fun UserSwitchCard( modifier: Modifier = Modifier, onClick: () -> Unit ) { Surface( modifier = modifier.height(140.dp), colors = ClickableSurfaceDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer), shape = ClickableSurfaceDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = onClick ) { Column( modifier = Modifier .fillMaxHeight() .padding(horizontal = 24.dp, vertical = 24.dp), verticalArrangement = Arrangement.SpaceAround, horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.user_homepage_user_switch), style = MaterialTheme.typography.titleLarge ) } } } @Composable private fun UserRow( modifier: Modifier = Modifier, username: String, face: String, uid: Long, level: Int, currentExp: Int, nextLevelExp: Int, showLabel: Boolean, labelUrl: String, followingUpCount: Int, onOpenFollowingUser: () -> Unit, onOpenUserSwitch: () -> Unit, coins: Float = 0f ) { val animateFollowingNumber by animateIntAsState( targetValue = followingUpCount, label = "animate following number" ) LazyRow( modifier = modifier.padding(vertical = 28.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start), contentPadding = PaddingValues(horizontal = 50.dp) ) { item { UserInfo( modifier = Modifier, face = face, username = username, uid = uid, level = level, currentExp = currentExp, nextLevelExp = nextLevelExp, showLabel = showLabel, labelUrl = labelUrl, onClick = { }, coins = coins ) } item { IncognitoModeCard() } item { FollowedUserCard( size = animateFollowingNumber, onClick = onOpenFollowingUser ) } item { UserSwitchCard( onClick = onOpenUserSwitch ) } } } @Composable private fun RecentVideosRow( modifier: Modifier = Modifier, videos: List, showMore: () -> Unit ) { val context = LocalContext.current VideosRow( modifier = modifier .padding(vertical = 8.dp), header = stringResource(R.string.user_homepage_recent), hideShowMore = false, showMore = showMore, videos = videos, onOpenSeasonInfo = { videoData -> SeasonInfoActivity.actionStart( context = context, epId = videoData.epId!!, proxyArea = ProxyArea.checkProxyArea(videoData.title) ) }, onOpenVideoInfo = { videoData -> VideoInfoActivity.actionStart(context, videoData.avid) } ) } @Composable private fun FollowingAnimeVideosRow( modifier: Modifier = Modifier, videos: List, showMore: () -> Unit ) { val context = LocalContext.current val density = LocalDensity.current var hasFocus by remember { mutableStateOf(false) } val titleFontSize by animateFloatAsState( targetValue = if (hasFocus) 30f else 14f, label = "title font size", animationSpec = tween( durationMillis = 120 ) ) var rowHeight by remember { mutableStateOf(0.dp) } Column( modifier = modifier .padding(vertical = 8.dp) .onFocusChanged { hasFocus = it.hasFocus } ) { Text( modifier = Modifier.padding(start = 50.dp), text = stringResource(R.string.user_homepage_anime), fontSize = titleFontSize.sp ) LazyRow( modifier = Modifier .padding(top = 15.dp) .onGloballyPositioned { rowHeight = with(density) { it.size.height.toDp() } }, horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(horizontal = 62.dp) ) { itemsIndexed( items = videos, key = { index, seasonCardData -> "$index-season-${seasonCardData.seasonId}" } ) { _, seasonCardData -> SeasonCard( modifier = Modifier.width(150.dp), data = seasonCardData, onClick = { SeasonInfoActivity.actionStart( context = context, seasonId = seasonCardData.seasonId, proxyArea = ProxyArea.checkProxyArea(seasonCardData.title) ) } ) } item { Button( modifier = Modifier.height(rowHeight), shape = ButtonDefaults.shape(shape = MaterialTheme.shapes.medium), onClick = showMore ) { Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center, ) { Text(text = "显示更多") } } } } } } @Composable private fun FavoriteVideosRow( modifier: Modifier = Modifier, videos: List, showMore: () -> Unit ) { val context = LocalContext.current VideosRow( modifier = modifier .padding(vertical = 8.dp), header = stringResource(R.string.user_homepage_favorite), hideShowMore = false, showMore = showMore, videos = videos, onOpenSeasonInfo = { videoData -> SeasonInfoActivity.actionStart( context = context, epId = videoData.epId!!, proxyArea = ProxyArea.checkProxyArea(videoData.title) ) }, onOpenVideoInfo = { videoData -> VideoInfoActivity.actionStart(context, videoData.avid) } ) } @Preview @Composable private fun UserInfoPreview() { BVTheme { UserInfo( face = "", username = "Username", uid = 12345, level = 6, currentExp = 1234, nextLevelExp = 2345, showLabel = false, labelUrl = "", onClick = {} ) } } @Preview @Composable private fun UserInfoFocusedPreview() { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } BVTheme { UserInfo( modifier = Modifier.focusRequester(focusRequester), face = "", username = "Username", uid = 12345, level = 6, currentExp = 1234, nextLevelExp = 2345, showLabel = false, labelUrl = "", onClick = {} ) } } @Preview @Composable private fun IncognitoModeCardOnPreview() { BVTheme { IncognitoModeCardContent( enabled = true, onClick = {} ) } } @Preview @Composable private fun IncognitoModeCardOffPreview() { BVTheme { IncognitoModeCardContent( enabled = false, onClick = {} ) } } @Preview @Composable private fun FollowedUserCardPreview() { BVTheme { FollowedUserCard( size = 466, onClick = {} ) } } @Preview @Composable private fun UserSwitchCardPreview() { BVTheme { UserSwitchCard( onClick = {} ) } } @Preview(device = "id:tv_1080p") @Composable private fun UserRowPreview() { BVTheme { UserRow( username = "Username", face = "", uid = 1234567890, level = 4, currentExp = 123, nextLevelExp = 431, showLabel = false, labelUrl = "", followingUpCount = 466, onOpenFollowingUser = { }, onOpenUserSwitch = {} ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/UserSwitchScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user import android.app.Activity import android.content.Intent import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.basicMarquee 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.BadgedBox import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale 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 androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.tv.material3.Border import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.Glow import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedButton import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import coil.compose.AsyncImage import dev.aaa1115910.bv.R import dev.aaa1115910.bv.component.QrImage import dev.aaa1115910.bv.entity.BvScheme import dev.aaa1115910.bv.entity.db.UserDB import dev.aaa1115910.bv.repository.UserRepository import dev.aaa1115910.bv.tv.activities.user.LoginActivity import dev.aaa1115910.bv.tv.activities.user.UserLockSettingsActivity import dev.aaa1115910.bv.tv.component.TvAlertDialog import dev.aaa1115910.bv.tv.screens.user.lock.UnlockSwitchUserContent import dev.aaa1115910.bv.ui.theme.BVTheme import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.viewmodel.UserSwitchViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.getKoin @Composable fun UserSwitchScreen( modifier: Modifier = Modifier, userSwitchViewModel: UserSwitchViewModel = koinViewModel(), userRepository: UserRepository = getKoin().get() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val lifecycleOwner = LocalLifecycleOwner.current val userList = userSwitchViewModel.userDbList var showUnlock by remember { mutableStateOf(false) } var unlockUser: UserDB? by remember { mutableStateOf(null) } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { scope.launch { //userSwitchViewModel.updateUserDbList() userSwitchViewModel.updateData() } } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } val unlockFocusRequester = remember { FocusRequester() } LaunchedEffect(showUnlock) { if (showUnlock) unlockFocusRequester.requestFocus() } Surface( modifier = modifier, shape = RoundedCornerShape(0.dp) ) { Box { UserSwitchContent( userList = userList, currentUid = userRepository.uid, loadingUserList = userSwitchViewModel.loading, onAddUser = { context.startActivity(Intent(context, LoginActivity::class.java)) }, onDeleteUser = { user -> scope.launch(Dispatchers.IO) { userSwitchViewModel.deleteUser(user) if (userList.isEmpty()) (context as Activity).finish() } }, onSwitchUser = { user -> if (user.uid != userRepository.uid && user.lock.isNotBlank()) { unlockUser = user showUnlock = true } else { scope.launch(Dispatchers.IO) { userSwitchViewModel.switchUser(user) (context as Activity).finish() } } }, onShowUserLockSettings = { uid -> UserLockSettingsActivity.actionStart(context, uid) } ) if (showUnlock) { UnlockSwitchUserContent( modifier = Modifier.focusRequester(unlockFocusRequester), userList = userList, unlockUser = unlockUser!!, onUnlockSuccess = { user -> scope.launch(Dispatchers.IO) { userSwitchViewModel.switchUser(user) (context as Activity).finish() } }, onCancel = { showUnlock = false } ) } } } } @Composable private fun UserSwitchContent( modifier: Modifier = Modifier, userList: List = emptyList(), currentUid: Long, loadingUserList: Boolean, onSwitchUser: (UserDB) -> Unit, onDeleteUser: (UserDB) -> Unit, onAddUser: () -> Unit, onShowUserLockSettings: (Long) -> Unit ) { val focusRequester = remember { FocusRequester() } var choosedUser by remember { mutableStateOf( UserDB( uid = -1, username = "None", avatar = "https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg", auth = "" ) ) } var isInManagerMode by remember { mutableStateOf(false) } var showUserMenuDialog by remember { mutableStateOf(false) } var showAuthDataDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { focusRequester.requestFocus() } LaunchedEffect(loadingUserList) { if (!loadingUserList) focusRequester.requestFocus() } Surface( modifier = modifier, shape = RoundedCornerShape(0.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .align(Alignment.TopCenter) .padding(top = 64.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.user_switch_title), style = MaterialTheme.typography.displaySmall ) } LazyRow( modifier = Modifier.focusRequester(focusRequester), horizontalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(horizontal = 12.dp) ) { itemsIndexed( items = userList, key = { index, user -> "$index-user-${user.uid}" } ) { _, user -> UserItem( avatar = user.avatar, username = user.username, lockEnabled = user.lock.isNotBlank(), onClick = { if (isInManagerMode) { choosedUser = user showUserMenuDialog = true } else { onSwitchUser(user) } } ) } if (!isInManagerMode) { item { AddUserItem( onClick = onAddUser ) } } } Button( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 64.dp), onClick = { isInManagerMode = !isInManagerMode } ) { if (isInManagerMode) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.AutoMirrored.Filled.ExitToApp, contentDescription = null ) Text(stringResource(R.string.user_switch_button_exit_manage_account)) } } else { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Icon(imageVector = Icons.Default.Settings, contentDescription = null) Text(stringResource(R.string.user_switch_button_manage_account)) } } } } } UserMenuDialog( show = showUserMenuDialog, onHideDialog = { showUserMenuDialog = false }, username = choosedUser.username, uid = choosedUser.uid, showTokenButton = choosedUser.uid == currentUid || choosedUser.lock.isBlank(), onShowUserAuthData = { showAuthDataDialog = true }, onDeleteUser = { showDeleteConfirmDialog = true }, onShowUserLockSettings = { uid -> isInManagerMode = false onShowUserLockSettings(uid) } ) UserAuthDataDialog( show = showAuthDataDialog, onHideDialog = { showAuthDataDialog = false }, userDB = choosedUser ) DeleteConfirmDialog( show = showDeleteConfirmDialog, onHideDialog = { showDeleteConfirmDialog = false }, userDB = choosedUser, onConfirm = { onDeleteUser(choosedUser) showDeleteConfirmDialog = false } ) } @Composable fun UserMenuDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, username: String, uid: Long, showTokenButton: Boolean, onShowUserAuthData: () -> Unit, onDeleteUser: () -> Unit, onShowUserLockSettings: (Long) -> Unit ) { val menuFocusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) { menuFocusRequester.requestFocus() } } if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = onHideDialog, title = { Text(text = username) }, text = { LazyColumn( modifier = Modifier.width(240.dp), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(horizontal = 12.dp) ) { if (showTokenButton) { item { UserMenuButton( modifier = Modifier.focusRequester(menuFocusRequester), text = stringResource(R.string.user_switch_menu_show_token), onClick = { onHideDialog() onShowUserAuthData() } ) } } item { UserMenuButton( modifier = Modifier .ifElse( !showTokenButton, Modifier.focusRequester(menuFocusRequester) ), text = stringResource(R.string.user_switch_menu_user_lock), onClick = { onHideDialog() onShowUserLockSettings(uid) } ) } item { UserMenuButton( text = stringResource(R.string.user_switch_menu_delete_account), onClick = { onHideDialog() onDeleteUser() }, color = MaterialTheme.colorScheme.errorContainer ) } } }, dismissButton = {}, confirmButton = {} ) } } @Composable fun UserAuthDataDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, userDB: UserDB ) { var qrContent by remember { mutableStateOf("") } LaunchedEffect(show) { if (show) { qrContent = BvScheme.QrToken( auth = userDB.auth, uid = userDB.uid, username = userDB.username, avatar = userDB.avatar ).buildUri() } } BackHandler(show) { onHideDialog() } if (show) { Scaffold( modifier .fillMaxSize(), topBar = { Box( modifier = Modifier.padding(start = 48.dp, top = 24.dp, bottom = 8.dp) ) { Text( text = userDB.username, fontSize = 48.sp ) } } ) { innerPadding -> Box( modifier = Modifier.padding(innerPadding) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Box( modifier = Modifier .weight(4f) .fillMaxHeight(), contentAlignment = Alignment.Center, ) { QrImage( modifier = Modifier .size(240.dp), content = qrContent ) } Box( modifier = Modifier .weight(6f) .padding(end = 60.dp) ) { Column( verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text( text = "扫码二维码以登录移动端", style = MaterialTheme.typography.displaySmall ) Text(text = userDB.auth) } } } } } } } @Composable private fun DeleteConfirmDialog( modifier: Modifier = Modifier, show: Boolean, onHideDialog: () -> Unit, userDB: UserDB, onConfirm: () -> Unit ) { val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } LaunchedEffect(show) { if (show) focusRequester.requestFocus(scope) } if (show) { TvAlertDialog( modifier = modifier, onDismissRequest = { onHideDialog() }, title = { Text(text = stringResource(R.string.delete_account_confirm_dialog_title)) }, text = { Text( text = stringResource( R.string.delete_account_confirm_dialog_text, userDB.username, userDB.uid ) ) }, confirmButton = { Button(onClick = { onConfirm() }) { Text(text = stringResource(R.string.delete_account_confirm_dialog_confirm)) } }, dismissButton = { OutlinedButton( modifier = Modifier.focusRequester(focusRequester), onClick = { onHideDialog() } ) { Text(text = stringResource(R.string.delete_account_confirm_dialog_dismiss)) } } ) } } @Composable fun UserItem( modifier: Modifier = Modifier, avatar: String, username: String, lockEnabled: Boolean = false, onClick: (() -> Unit)? = null ) { Column( modifier = modifier.width(120.dp), horizontalAlignment = Alignment.CenterHorizontally ) { if (onClick != null) { BadgedBox( modifier = Modifier.padding(18.dp), badge = { if (lockEnabled) { Icon(imageVector = Icons.Default.Lock, contentDescription = null) } } ) { Surface( modifier = Modifier .size(80.dp), colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant ), shape = ClickableSurfaceDefaults.shape( shape = CircleShape ), glow = ClickableSurfaceDefaults.glow( focusedGlow = Glow( elevationColor = MaterialTheme.colorScheme.border, elevation = 16.dp ) ), onClick = onClick, border = ClickableSurfaceDefaults.border( focusedBorder = Border( BorderStroke( width = 3.dp, color = MaterialTheme.colorScheme.border.copy(alpha = 0.7f) ) ) ) ) { AsyncImage( modifier = Modifier .size(80.dp) .clip(CircleShape), model = avatar, contentDescription = null, contentScale = ContentScale.FillBounds ) } } } else { Surface( modifier = Modifier .padding(18.dp) .size(80.dp), colors = SurfaceDefaults.colors( containerColor = Color.DarkGray ), shape = CircleShape ) { AsyncImage( modifier = Modifier .size(80.dp) .clip(CircleShape), model = avatar, contentDescription = null, contentScale = ContentScale.FillBounds ) } } Box( modifier = Modifier.height(26.dp), contentAlignment = Alignment.Center ) { Text( modifier = Modifier .fillMaxWidth() .basicMarquee(), text = username, style = MaterialTheme.typography.titleMedium, maxLines = 1, textAlign = TextAlign.Center ) } } } @Composable private fun AddUserItem( modifier: Modifier = Modifier, onClick: () -> Unit ) { Column( modifier = modifier.width(120.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Surface( modifier = Modifier .padding(18.dp) .size(80.dp), colors = ClickableSurfaceDefaults.colors( containerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant ), shape = ClickableSurfaceDefaults.shape( shape = CircleShape ), glow = ClickableSurfaceDefaults.glow( focusedGlow = Glow( elevationColor = MaterialTheme.colorScheme.inverseSurface, elevation = 16.dp ) ), onClick = onClick, border = ClickableSurfaceDefaults.border( focusedBorder = Border( BorderStroke( width = 3.dp, color = MaterialTheme.colorScheme.border.copy(alpha = 0.7f) ) ) ) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( modifier = Modifier.size(40.dp), imageVector = Icons.Default.Add, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } Box( modifier = Modifier.height(26.dp), contentAlignment = Alignment.Center ) { Text( modifier = Modifier .fillMaxWidth() .basicMarquee(), text = stringResource(R.string.user_switch_add_user), style = MaterialTheme.typography.titleMedium, maxLines = 1, textAlign = TextAlign.Center ) } } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun UserItemPreview() { BVTheme { UserItem( avatar = "", username = "This is a user name", onClick = {}, lockEnabled = true ) } } @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun AddUserItemPreview() { BVTheme { AddUserItem( onClick = {} ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun UserSwitchContentPreview() { BVTheme { UserSwitchContent( userList = listOf( UserDB( uid = 0, username = "大楚兴 陈胜王 大楚兴 陈胜王", avatar = "0https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg", auth = "{xxx1}" ), UserDB( uid = 1, username = "This is a long username", avatar = "0https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg", auth = "{xxx2}", lock = "rdrd" ), UserDB( uid = 2, username = "\uD835\uDD4F", avatar = "0https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg", auth = "{xxx3}" ) ), currentUid = 0L, loadingUserList = false, onSwitchUser = {}, onDeleteUser = {}, onAddUser = {}, onShowUserLockSettings = {} ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun UserMenuDialogPreview() { BVTheme { UserMenuDialog( show = true, onHideDialog = {}, username = "This is a user name", uid = 0, showTokenButton = true, onShowUserAuthData = {}, onDeleteUser = {}, onShowUserLockSettings = {} ) } } @Preview(device = "id:tv_1080p") @Preview(device = "id:tv_1080p", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun UserAuthDataDialogPreview() { BVTheme { UserAuthDataDialog( show = true, onHideDialog = {}, userDB = UserDB( uid = 0, username = "Android Studio Official", avatar = "0https://i0.hdslb.com/bfs/article/b6b843d84b84a3ba5526b09ebf538cd4b4c8c3f3.jpg", auth = "this is a long auth data string that is used to test the dialog layout and should be long enough to wrap into multiple lines." ), ) } } @Composable private fun UserMenuButton( modifier: Modifier = Modifier, text: String, onClick: () -> Unit, color: Color? = null ) { Button( modifier = modifier .fillMaxWidth() .height(48.dp), shape = ButtonDefaults.shape(shape = MaterialTheme.shapes.medium), colors = if (color != null) ButtonDefaults.colors(containerColor = color) else ButtonDefaults.colors(), onClick = onClick ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( text = text, style = MaterialTheme.typography.bodyLarge, ) } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/lock/UnlockSwitchUserContent.kt ================================================ package dev.aaa1115910.bv.tv.screens.user.lock import android.view.KeyEvent import androidx.activity.compose.BackHandler 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.entity.db.UserDB import dev.aaa1115910.bv.tv.screens.user.UserItem import dev.aaa1115910.bv.util.toast import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable fun UnlockSwitchUserContent( modifier: Modifier = Modifier, userList: List, unlockUser: UserDB?, onUnlockSuccess: (UserDB) -> Unit, onCancel: () -> Unit ) { val context = LocalContext.current val scope = rememberCoroutineScope() val defaultFocusRequester = remember { FocusRequester() } var inputPassword by remember { mutableStateOf("") } val inputShow by remember { derivedStateOf { inputPassword .replace("u", "*") .replace("d", "*") .replace("l", "*") .replace("r", "*") } } val unselectedUserAlpha by remember { mutableFloatStateOf(0.4f) } LaunchedEffect(Unit) { scope.launch { delay(200) println("request default focus") defaultFocusRequester.requestFocus() } } BackHandler(true) { } Surface( modifier = modifier .clickable {} .focusRequester(defaultFocusRequester) .onPreviewKeyEvent { if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent true when (it.key) { Key.DirectionUp -> inputPassword += "u" Key.DirectionDown -> inputPassword += "d" Key.DirectionLeft -> inputPassword += "l" Key.DirectionRight -> inputPassword += "r" Key.DirectionCenter -> { if (unlockUser?.lock == inputPassword) { onUnlockSuccess(unlockUser) } else { R.string.user_lock_toast_password_error.toast(context) inputPassword = "" } } Key.Back -> { if (inputPassword.isNotBlank()) { inputPassword = inputPassword.drop(1) } else { onCancel() } } } return@onPreviewKeyEvent true }, shape = RoundedCornerShape(0.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .align(Alignment.TopCenter) .padding(top = 64.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.user_lock_title_input_password), style = MaterialTheme.typography.displaySmall ) } LazyRow( horizontalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(horizontal = 12.dp) ) { itemsIndexed( items = userList, key = { index, user -> "$index-user-${user.uid}" } ) { _, user -> UserItem( modifier = Modifier .ifElse({ user != unlockUser }, Modifier.alpha(unselectedUserAlpha)), avatar = user.avatar, username = user.username, lockEnabled = user.lock.isNotBlank(), ) } } Text( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 96.dp), text = inputShow, style = MaterialTheme.typography.displayLarge ) Text( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 48.dp), text = stringResource(R.string.user_lock_input_tip), color = MaterialTheme.colorScheme.onSurface.copy(0.6f) ) } } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/lock/UnlockUserScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user.lock import android.view.KeyEvent import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape 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.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.util.ifElse import dev.aaa1115910.bv.entity.db.UserDB import dev.aaa1115910.bv.tv.screens.user.UserItem import dev.aaa1115910.bv.util.requestFocus import dev.aaa1115910.bv.util.toast import dev.aaa1115910.bv.viewmodel.UserSwitchViewModel import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @Composable fun UnlockUserScreen( modifier: Modifier = Modifier, userSwitchViewModel: UserSwitchViewModel = koinViewModel(), onUnlockSuccess: (UserDB) -> Unit ) { val scope = rememberCoroutineScope() val userList = userSwitchViewModel.userDbList var selectedUser: UserDB? by remember { mutableStateOf(null) } LaunchedEffect(Unit) { userSwitchViewModel.updateData() } UnlockUserContent( modifier = modifier, userList = userList, selectedUser = selectedUser, onSelectedUserChange = { user -> selectedUser = user }, onUnlockSuccess = { user -> scope.launch { userSwitchViewModel.switchUser(user) onUnlockSuccess(user) } } ) } @Composable private fun UnlockUserContent( modifier: Modifier = Modifier, userList: List, selectedUser: UserDB?, onSelectedUserChange: (UserDB) -> Unit, onUnlockSuccess: (UserDB) -> Unit ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger("UnlockUserContent") val inputFocusRequester = remember { FocusRequester() } val defaultFocusRequester = remember { FocusRequester() } var inputPassword by remember { mutableStateOf("") } val inputShow by remember { derivedStateOf { inputPassword .replace("u", "*") .replace("d", "*") .replace("l", "*") .replace("r", "*") } } var unlockState by remember { mutableStateOf(UnlockState.ChooseUser) } val unChosenUserAlpha by animateFloatAsState( targetValue = when (unlockState) { UnlockState.ChooseUser -> 1f UnlockState.InputPassword -> 0.4f }, label = "unchosen user alpha" ) LaunchedEffect(userList) { scope.launch { delay(200) defaultFocusRequester.requestFocus(scope) } } LaunchedEffect(unlockState) { scope.launch { delay(100) inputFocusRequester.requestFocus() } } BackHandler(true) { } Surface( modifier = modifier .ifElse({ unlockState == UnlockState.InputPassword }, Modifier.clickable {}) .focusRequester(inputFocusRequester) .onPreviewKeyEvent { when (unlockState) { UnlockState.ChooseUser -> return@onPreviewKeyEvent false UnlockState.InputPassword -> { if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent true when (it.key) { Key.DirectionUp -> inputPassword += "u" Key.DirectionDown -> inputPassword += "d" Key.DirectionLeft -> inputPassword += "l" Key.DirectionRight -> inputPassword += "r" Key.DirectionCenter -> { if (selectedUser?.lock == inputPassword) { onUnlockSuccess(selectedUser) } else { "密码错误".toast(context) inputPassword = "" } } Key.Back -> { if (inputPassword.isNotBlank()) { inputPassword = inputPassword.drop(1) } else { unlockState = UnlockState.ChooseUser defaultFocusRequester.requestFocus() } } } return@onPreviewKeyEvent true } } }, shape = RoundedCornerShape(0.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .align(Alignment.TopCenter) .padding(top = 64.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = when (unlockState) { UnlockState.ChooseUser -> stringResource(R.string.user_lock_title_choose_user) UnlockState.InputPassword -> stringResource(R.string.user_lock_title_input_password) }, style = MaterialTheme.typography.displaySmall ) } LazyRow( modifier = Modifier.focusRequester(defaultFocusRequester), horizontalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(horizontal = 12.dp) ) { itemsIndexed( items = userList, key = { index, user -> "$index-user-${user.uid}" } ) { _, user -> UserItem( modifier = Modifier .ifElse({ user != selectedUser }, Modifier.alpha(unChosenUserAlpha)), avatar = user.avatar, username = user.username, lockEnabled = user.lock.isNotBlank(), onClick = { logger.info { "Choose user ${user.uid}" } if (user.lock.isNotBlank()) { onSelectedUserChange(user) unlockState = UnlockState.InputPassword } else { onSelectedUserChange(user) onUnlockSuccess(user) } }.takeIf { unlockState == UnlockState.ChooseUser } ) } } Text( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 96.dp), text = inputShow, style = MaterialTheme.typography.displayLarge ) if (unlockState == UnlockState.InputPassword) { Text( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 48.dp), text = stringResource(R.string.user_lock_input_tip), color = MaterialTheme.colorScheme.onSurface.copy(0.6f) ) } } } } private enum class UnlockState { ChooseUser, InputPassword } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/screens/user/lock/UserLockSettingsScreen.kt ================================================ package dev.aaa1115910.bv.tv.screens.user.lock import android.app.Activity import androidx.activity.compose.BackHandler 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.RoundedCornerShape 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import dev.aaa1115910.bv.R import dev.aaa1115910.bv.entity.db.UserDB import dev.aaa1115910.bv.repository.UserRepository import dev.aaa1115910.bv.tv.screens.user.UserItem import dev.aaa1115910.bv.util.toast import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.launch import org.koin.compose.getKoin @Composable fun UserLockSettingsScreen( modifier: Modifier = Modifier, userRepository: UserRepository = getKoin().get() ) { val context = LocalContext.current val scope = rememberCoroutineScope() val logger = KotlinLogging.logger("UserLockSettingsScreen") var user by remember { mutableStateOf( UserDB( uid = -1, username = "None", avatar = "", auth = "" ) ) } LaunchedEffect(Unit) { val intent = (context as Activity).intent if (intent.hasExtra("uid")) { val uid = intent.getLongExtra("uid", 0) userRepository.findUserByUid(uid) ?.let { user = it } ?: let { context.finish() } logger.debug { "user $uid lock: ${user.lock}" } } else { context.finish() } } UserLockSettingsContent( modifier = modifier, user = user, onUpdateUser = { scope.launch { userRepository.updateUser(it) (context as Activity).finish() } }, onExit = { (context as Activity).finish() } ) } @Composable private fun UserLockSettingsContent( modifier: Modifier = Modifier, user: UserDB, onUpdateUser: (UserDB) -> Unit, onExit: () -> Unit ) { val context = LocalContext.current val focusRequester = remember { FocusRequester() } var inputState by remember { mutableStateOf(InputState.InputOldPassword) } var inputPassword by remember { mutableStateOf("") } var lastInput by remember { mutableStateOf("") } val inputShow by remember { derivedStateOf { inputPassword .replace("u", "↑") .replace("d", "↓") .replace("l", "←") .replace("r", "→") } } LaunchedEffect(Unit) { focusRequester.requestFocus() } LaunchedEffect(user) { inputState = if (user.lock.isNotBlank()) InputState.InputOldPassword else InputState.InputNewPassword } BackHandler(inputPassword.isNotEmpty()) { } Surface( modifier = modifier .clickable {} .focusRequester(focusRequester) .onPreviewKeyEvent { keyEvent -> if (keyEvent.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) { return@onPreviewKeyEvent true } when (keyEvent.key) { Key.DirectionUp -> inputPassword += "u" Key.DirectionDown -> inputPassword += "d" Key.DirectionLeft -> inputPassword += "l" Key.DirectionRight -> inputPassword += "r" Key.DirectionCenter -> { when (inputState) { InputState.InputOldPassword -> { if (inputPassword == user.lock) { inputState = InputState.InputNewPassword inputPassword = "" } else { R.string.user_lock_toast_password_error.toast(context) inputPassword = "" } } InputState.InputNewPassword -> { if (inputPassword.isBlank()) { R.string.user_lock_toast_password_removed.toast(context) user.lock = "" onUpdateUser(user) } else { lastInput = inputPassword inputPassword = "" inputState = InputState.ConfirmNewPassword } } InputState.ConfirmNewPassword -> { if (inputPassword == lastInput) { user.lock = inputPassword onUpdateUser(user) } else { R.string.user_lock_toast_password_different.toast(context) inputPassword = "" inputState = InputState.InputNewPassword } } } } Key.Back -> { if (inputPassword.isNotEmpty()) { inputPassword = inputPassword.dropLast(1) } else { onExit() } } } true }, shape = RoundedCornerShape(0.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Column( modifier = Modifier .align(Alignment.TopCenter) .padding(top = 64.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = when (inputState) { InputState.InputOldPassword -> stringResource(R.string.user_lock_title_input_old_password) InputState.InputNewPassword -> stringResource(R.string.user_lock_title_input_new_password) InputState.ConfirmNewPassword -> stringResource(R.string.user_lock_title_input_new_password_again) }, style = MaterialTheme.typography.displaySmall ) } LazyRow( modifier = Modifier.focusRequester(focusRequester), horizontalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(horizontal = 12.dp) ) { item { UserItem( avatar = user.avatar, username = user.username ) } } Text( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 96.dp), text = inputShow, style = MaterialTheme.typography.displayLarge ) Text( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 48.dp), text = stringResource(R.string.user_lock_input_tip), color = MaterialTheme.colorScheme.onSurface.copy(0.6f) ) } } } private enum class InputState { InputOldPassword, InputNewPassword, ConfirmNewPassword } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/util/NavItemsExtensions.kt ================================================ package dev.aaa1115910.bv.tv.util import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import dev.aaa1115910.biliapi.entity.live.LiveAreaGroup import dev.aaa1115910.bv.tv.component.HomeTopNavItem import dev.aaa1115910.bv.tv.component.PgcTopNavItem import dev.aaa1115910.bv.tv.component.TopNavItem import dev.aaa1115910.bv.tv.component.UgcTopNavItem import dev.aaa1115910.bv.tv.screens.main.DrawerItem import dev.aaa1115910.bv.util.Prefs import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map /** * 导航项配置数据类 */ data class NavItemConfig( val ordinal: Int, val hidden: Boolean ) // ======================== 直播导航项配置 ======================== /** * 直播导航项配置数据类 * @param id 标识符:"R"=推荐, "F"=关注, 数字字符串=主分区ID * @param hidden 是否隐藏 */ data class LiveNavItemConfig( val id: String, val hidden: Boolean ) /** * 缓存的直播分区信息 */ data class CachedLiveAreaInfo( val id: Int, val name: String ) /** * 序列化直播分区列表为缓存字符串 * 格式: "id1:name1,id2:name2,..." */ fun serializeLiveAreaGroups(areaGroups: List): String { return areaGroups.joinToString(",") { "${it.id}:${it.name}" } } /** * 解析缓存的直播分区字符串 * @return 分区ID和名称的列表 */ fun parseCachedLiveAreaGroups(cacheString: String): List { if (cacheString.isBlank()) return emptyList() return cacheString.split(",").mapNotNull { part -> val colonIndex = part.indexOf(':') if (colonIndex < 0) return@mapNotNull null val id = part.substring(0, colonIndex).toIntOrNull() ?: return@mapNotNull null val name = part.substring(colonIndex + 1) CachedLiveAreaInfo(id, name) } } /** * 解析直播导航排序字符串为配置列表(用于设置对话框) * @param orderString 逗号分隔的标识符列表,"-"前缀表示隐藏 * @param cachedAreas 缓存的分区信息列表 * @param isLoggedIn 是否已登录(决定是否包含关注项) * @return 直播导航项配置列表(按显示顺序) */ fun parseLiveNavItemsOrderToConfig( orderString: String, cachedAreas: List, isLoggedIn: Boolean ): List { if (orderString.isBlank()) { // 默认配置:推荐 → (关注) → 按缓存顺序的各分区 return buildList { add(LiveNavItemConfig("R", false)) if (isLoggedIn) add(LiveNavItemConfig("F", false)) cachedAreas.forEach { add(LiveNavItemConfig(it.id.toString(), false)) } } } val configs = orderString.split(",").mapNotNull { part -> val trimmed = part.trim() if (trimmed.isEmpty()) return@mapNotNull null val isHidden = trimmed.startsWith("-") val id = if (isHidden) trimmed.substring(1) else trimmed if (id.isEmpty()) return@mapNotNull null LiveNavItemConfig(id, isHidden) } // 收集已有的ID val existingIds = configs.map { it.id }.toSet() // 追加默认项中不存在的(新分区等) val result = configs.toMutableList() if ("R" !in existingIds) result.add(0, LiveNavItemConfig("R", false)) if (isLoggedIn && "F" !in existingIds) { val rIndex = result.indexOfFirst { it.id == "R" } result.add(rIndex + 1, LiveNavItemConfig("F", false)) } // 追加缓存中有但配置中没有的新分区 cachedAreas.forEach { area -> val areaId = area.id.toString() if (areaId !in existingIds) { result.add(LiveNavItemConfig(areaId, false)) } } // 过滤掉未登录时的"关注"项 return if (!isLoggedIn) result.filter { it.id != "F" } else result } /** * 获取直播导航项的显示名称 */ fun getLiveNavItemDisplayName(id: String, cachedAreas: List): String { return when (id) { "R" -> "推荐" "F" -> "关注" else -> { val areaId = id.toIntOrNull() cachedAreas.firstOrNull { it.id == areaId }?.name ?: "未知分区($id)" } } } // 用于 LiveContent 的 TopNavItem 包装 private object LiveRecommendNavItemHolder : TopNavItem { override fun getDisplayName(context: Context): String = "推荐" } private object LiveFollowingNavItemHolder : TopNavItem { override fun getDisplayName(context: Context): String = "关注" } private data class LiveParentAreaNavItemHolder(val group: LiveAreaGroup) : TopNavItem { override fun getDisplayName(context: Context): String = group.name } /** * 解析直播导航排序字符串,返回过滤后的 TopNavItem 列表(用于 LiveContent) * @param orderString 排序配置字符串 * @param areaGroups 当前 API 返回的主分区列表 * @param isLoggedIn 是否已登录 * @return 过滤和排序后的 TopNavItem 列表 */ fun parseLiveNavItemsOrder( orderString: String, areaGroups: List, isLoggedIn: Boolean ): List { if (orderString.isBlank()) { // 默认:推荐 → (关注) → API 返回顺序的各分区 return buildList { add(LiveRecommendNavItemHolder) if (isLoggedIn) add(LiveFollowingNavItemHolder) addAll(areaGroups.map { LiveParentAreaNavItemHolder(it) }) } } val areaGroupMap = areaGroups.associateBy { it.id } val existingIds = mutableSetOf() val result = orderString.split(",").mapNotNull { part -> val trimmed = part.trim() if (trimmed.isEmpty()) return@mapNotNull null val isHidden = trimmed.startsWith("-") val id = if (isHidden) trimmed.substring(1) else trimmed if (id.isEmpty() || isHidden) return@mapNotNull null existingIds.add(id) when (id) { "R" -> LiveRecommendNavItemHolder "F" -> if (isLoggedIn) LiveFollowingNavItemHolder else null else -> { val areaId = id.toIntOrNull() ?: return@mapNotNull null areaGroupMap[areaId]?.let { LiveParentAreaNavItemHolder(it) } } } }.toMutableList() // 追加配置中没有的新分区(API 新增的) areaGroups.forEach { group -> if (group.id.toString() !in existingIds) { result.add(LiveParentAreaNavItemHolder(group)) } } return result } /** * 从 TopNavItem 获取直播导航项的 ID(用于 LiveContent 恢复选中状态等) */ fun getLiveNavItemId(item: TopNavItem): String? { return when (item) { is LiveRecommendNavItemHolder -> "R" is LiveFollowingNavItemHolder -> "F" is LiveParentAreaNavItemHolder -> item.group.id.toString() else -> null } } /** * 从 TopNavItem 获取 LiveAreaGroup(仅分区项有值) */ fun getLiveNavItemAreaGroup(item: TopNavItem): LiveAreaGroup? { return (item as? LiveParentAreaNavItemHolder)?.group } /** * 判断 TopNavItem 是否是直播推荐项 */ fun isLiveRecommendItem(item: TopNavItem): Boolean = item is LiveRecommendNavItemHolder /** * 判断 TopNavItem 是否是直播关注项 */ fun isLiveFollowingItem(item: TopNavItem): Boolean = item is LiveFollowingNavItemHolder /** * 判断 TopNavItem 是否是直播分区项 */ fun isLiveAreaItem(item: TopNavItem): Boolean = item is LiveParentAreaNavItemHolder /** * 获取根据设置过滤和排序后的直播导航项列表(Flow 版本) * 需要配合 areaGroups 使用,因此不能像 UGC/PGC 那样纯 Flow */ val liveNavItemsOrderFlow: Flow get() = Prefs.liveNavItemsOrderFlow private fun parseTopNavItemsOrder(orderString: String, entries: List): List { if (orderString.isBlank()) return entries return orderString .split(",") .mapNotNull { part -> val trimmed = part.trim() val isHidden = trimmed.startsWith("-") val actualOrdinal = trimmed.removePrefix("-").toIntOrNull() ?: return@mapNotNull null actualOrdinal to isHidden } .filter { !it.second } .mapNotNull { (ordinal, _) -> entries.getOrNull(ordinal) } } /** * 获取根据设置过滤和排序后的首页导航项列表 */ val homeNavItemsFlow: Flow> get() = Prefs.homeNavItemsOrderFlow.map { orderString -> parseHomeNavItemsOrder(orderString) } /** * 获取根据设置过滤和排序后的 UGC 顶部导航项列表 */ val ugcNavItemsFlow: Flow> get() = Prefs.ugcNavItemsOrderFlow.map { orderString -> parseUgcTopNavItemsOrder(orderString) } /** * 获取根据设置过滤和排序后的 PGC 顶部导航项列表 */ val pgcNavItemsFlow: Flow> get() = Prefs.pgcNavItemsOrderFlow.map { orderString -> parsePgcTopNavItemsOrder(orderString) } /** * 解析导航项排序字符串 * @param orderString 逗号分隔的 ordinal 列表,负数表示隐藏 * @return 过滤和排序后的导航项列表 */ fun parseHomeNavItemsOrder(orderString: String): List { return parseTopNavItemsOrder(orderString, HomeTopNavItem.entries) } fun parseUgcTopNavItemsOrder(orderString: String): List { return parseTopNavItemsOrder(orderString, UgcTopNavItem.entries) } fun parsePgcTopNavItemsOrder(orderString: String): List { return parseTopNavItemsOrder(orderString, PgcTopNavItem.entries) } /** * 将指定导航项移到第一位并取消隐藏 * 用于切换默认标签时,将新默认标签移到第一位 * @param orderString 当前排序配置字符串 * @param ordinal 要移到第一位的导航项 ordinal * @return 更新后的排序配置字符串 */ fun moveNavItemToFirstAndUnhide(orderString: String, ordinal: Int): String { return moveNavItemToFirstAndUnhide(orderString = orderString, ordinal = ordinal, entriesCount = 0) } /** * 将指定导航项移到第一位并取消隐藏 * @param orderString 当前排序配置字符串 * @param ordinal 要移到第一位的导航项 ordinal * @param entriesCount 枚举项数量;当 orderString 为空时用于生成默认序列 */ fun moveNavItemToFirstAndUnhide(orderString: String, ordinal: Int, entriesCount: Int): String { val normalizedOrderString = if (orderString.isBlank()) { if (entriesCount <= 0) return orderString (0 until entriesCount).joinToString(",") } else { orderString } val parts = normalizedOrderString.split(",").map { part -> val trimmed = part.trim() val isHidden = trimmed.startsWith("-") val absNum = trimmed.removePrefix("-").toIntOrNull() ?: return@map 0 to false absNum to isHidden } // 找到目标项 val targetItem = parts.find { it.first == ordinal } if (targetItem == null) return normalizedOrderString // 构建新的顺序:目标项在前,其他项按原顺序在后 val otherItems = parts.filter { it.first != ordinal } val newParts = listOf( ordinal.toString() // 默认标签在第一位,取消隐藏 ) + otherItems.map { (ord, hidden) -> if (hidden) "-$ord" else "$ord" } return newParts.joinToString(",") } /** * 解析排序字符串为配置列表 * @param orderString 逗号分隔的 ordinal 列表,负数表示隐藏 * @return 导航项配置列表(按显示顺序) */ fun parseNavItemsOrderToConfig(orderString: String): List { return parseNavItemsOrderToConfig(orderString, HomeTopNavItem.entries.size) } /** * 解析排序字符串为配置列表(通用版本) * @param orderString 逗号分隔的 ordinal 列表,负数表示隐藏 * @param entriesCount 枚举项数量 */ fun parseNavItemsOrderToConfig(orderString: String, entriesCount: Int): List { if (entriesCount <= 0) return emptyList() if (orderString.isBlank()) { return (0 until entriesCount).map { NavItemConfig(it, false) } } return orderString .split(",") .mapNotNull { part -> val trimmed = part.trim() val isHidden = trimmed.startsWith("-") val actualOrdinal = trimmed.removePrefix("-").toIntOrNull() ?: return@mapNotNull null if (actualOrdinal !in 0 until entriesCount) return@mapNotNull null NavItemConfig(actualOrdinal, isHidden) } } // ======================== 主导航(左侧侧栏)配置 ======================== /** * 可配置的主导航项列表(不含 User 和 Settings) */ val configurableDrawerItems = listOf( DrawerItem.Search, DrawerItem.Home, DrawerItem.UGC, DrawerItem.PGC, DrawerItem.Live ) /** * 解析主导航排序字符串为 DrawerItem 列表(过滤隐藏项) */ fun parseDrawerNavItemsOrder(orderString: String): List { if (orderString.isBlank()) return configurableDrawerItems return orderString .split(",") .mapNotNull { part -> val trimmed = part.trim() val isHidden = trimmed.startsWith("-") if (isHidden) return@mapNotNull null val ordinal = trimmed.toIntOrNull() ?: return@mapNotNull null DrawerItem.entries.getOrNull(ordinal) } .filter { it in configurableDrawerItems } } /** * 解析主导航排序字符串为配置列表(用于设置对话框) */ fun parseDrawerNavItemsOrderToConfig(orderString: String): List { if (orderString.isBlank()) { return configurableDrawerItems.mapIndexed { _, item -> NavItemConfig(item.ordinal, false) } } val configs = orderString .split(",") .mapNotNull { part -> val trimmed = part.trim() val isHidden = trimmed.startsWith("-") val ordinal = trimmed.removePrefix("-").toIntOrNull() ?: return@mapNotNull null val drawerItem = DrawerItem.entries.getOrNull(ordinal) ?: return@mapNotNull null if (drawerItem !in configurableDrawerItems) return@mapNotNull null NavItemConfig(ordinal, isHidden) } // 追加配置中缺失的可配置项 val existingOrdinals = configs.map { it.ordinal }.toSet() val missingConfigs = configurableDrawerItems .filter { it.ordinal !in existingOrdinals } .map { NavItemConfig(it.ordinal, false) } return configs + missingConfigs } /** * 保存主导航排序配置 */ fun saveDrawerNavConfigs(navConfigs: List) { val finalOrderString = navConfigs.joinToString(",") { config -> if (config.hidden) "-${config.ordinal}" else "${config.ordinal}" } Prefs.drawerNavItemsOrder = finalOrderString } /** * 获取根据设置过滤和排序后的主导航项列表(Flow 版本) */ val drawerNavItemsFlow: Flow> get() = Prefs.drawerNavItemsOrderFlow.map { orderString -> parseDrawerNavItemsOrder(orderString) } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/util/PlayerActivityUtil.kt ================================================ package dev.aaa1115910.bv.tv.util import android.content.Context import dev.aaa1115910.bv.entity.proxy.ProxyArea import dev.aaa1115910.bv.tv.activities.video.RemoteControllerPanelDemoActivity import dev.aaa1115910.bv.tv.activities.video.VideoPlayerV3Activity import dev.aaa1115910.bv.util.Prefs fun launchPlayerActivity( context: Context, avid: Long, cid: Long, title: String, partTitle: String, played: Int, fromSeason: Boolean, subType: Int? = null, epid: Int? = null, seasonId: Int? = null, isVerticalVideo: Boolean = false, proxyArea: ProxyArea = ProxyArea.MainLand, playerIconIdle: String = "", playerIconMoving: String = "", play: Long = 0, danmaku: Int = 0, like: Int = 0, coin: Int = 0, favorite: Int = 0, upName: String = "", upId: Long = 0L, upFace: String = "", pubTime: String = "" ) { if (Prefs.showedRemoteControllerPanelDemo) { VideoPlayerV3Activity.actionStart( context, avid, cid, title, partTitle, played, fromSeason, subType, epid, seasonId, isVerticalVideo, proxyArea, playerIconIdle, playerIconMoving, play, danmaku, like, coin, favorite, upName, upId, upFace, pubTime ) } else { RemoteControllerPanelDemoActivity.actionStart( context, avid, cid, title, partTitle, played, fromSeason, subType, epid, seasonId, isVerticalVideo, proxyArea, playerIconIdle, playerIconMoving, play, danmaku, like, coin, favorite, upName, upId, upFace, pubTime ) } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/util/ProvideListBringIntoViewSpec.kt ================================================ package dev.aaa1115910.bv.tv.util import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.BringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.math.abs /** * Provides a [BringIntoViewSpec] that calculates the scroll offset for a child item in a LazyList * with intelligent positioning logic. * * The positioning logic: * 1. If the focused element is fully visible, don't scroll * 2. If the focused element is in the upper, align its top edge with container top * 3. If the focused element is in the lower, align its bottom edge with container bottom * * @param padding 容器上下左右预留的内边距。单位是dp * 注意:延迟列表默认只组合可见项,必须留边距露出一点点下一行用来确保将要获得焦点的项已被组合,否则下移的时候焦点会选中下一行的第一个,上移的时候焦点会选中上一行的最后一个(焦点乱跳的问题) * 另外,本应用列表用的视频卡片组件有发光效果,不留边距会没显示不全。 * @param topPadding 容器上边距。默认与 [padding] 相同 * @param bottomPadding 容器下边距。默认与 [padding] 相同 * @param content 包含在 LazyList 中的内容 */ @OptIn(ExperimentalFoundationApi::class) @Composable fun ProvideListBringIntoViewSpec( padding: Dp = 24.dp, topPadding: Dp = padding, bottomPadding: Dp = padding, content: @Composable () -> Unit, ) { val density = LocalDensity.current val topPaddingPx = remember(topPadding, density) { with(density) { topPadding.toPx() } } val bottomPaddingPx = remember(bottomPadding, density) { with(density) { bottomPadding.toPx() } } val bringIntoViewSpec = remember(topPaddingPx, bottomPaddingPx) { object : BringIntoViewSpec { override fun calculateScrollDistance( offset: Float, size: Float, containerSize: Float ): Float = calculateScrollDistanceWithPadding( offset = offset, size = size, containerSize = containerSize, topPadding = topPaddingPx, bottomPadding = bottomPaddingPx, ) } } CompositionLocalProvider( LocalBringIntoViewSpec provides bringIntoViewSpec, content = content, ) } private fun calculateScrollDistanceWithPadding( offset: Float, size: Float, containerSize: Float, topPadding: Float, // 容器上边距 bottomPadding: Float, // 容器下边距 ): Float { val trailingEdge = offset + size + bottomPadding val leadingEdge = offset - topPadding return when { // 如果组件已经完整显示,不滚动 leadingEdge >= 0 && trailingEdge <= containerSize -> 0f // 如果组件可见但比容器大,不滚动 leadingEdge < 0 && trailingEdge > containerSize -> 0f // 找出使其中一条边与容器的边重合所需的最小滚动量 abs(leadingEdge) < abs(trailingEdge - containerSize) -> leadingEdge else -> trailingEdge - containerSize } } ================================================ FILE: app/tv/src/main/kotlin/dev/aaa1115910/bv/tv/util/TvLazyListFocusRestorer.kt ================================================ package dev.aaa1115910.bv.tv.util import android.view.KeyEvent import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.input.key.onPreviewKeyEvent import dev.aaa1115910.bv.entity.carddata.VideoCardData @Stable class TvLazyListFocusRestorer internal constructor( val fallbackFocusRequester: FocusRequester ) { fun containerModifier(modifier: Modifier = Modifier): Modifier { return modifier.focusRestorer(fallbackFocusRequester) } fun firstItemModifier(index: Int, modifier: Modifier = Modifier): Modifier { return if (index == 0) { modifier.focusRequester(fallbackFocusRequester) } else { modifier } } } @Composable fun rememberTvLazyListFocusRestorer( fallbackFocusRequester: FocusRequester = remember { FocusRequester() } ): TvLazyListFocusRestorer { return remember(fallbackFocusRequester) { TvLazyListFocusRestorer(fallbackFocusRequester) } } fun VideoCardData.stableItemKey(): Any { return when { seasonId != null -> "season-$seasonId-${epId ?: 0}-$upId" avid > 0 -> "av-$avid-$upId" else -> "$title|$upId" } } fun Modifier.blockDownFocusExitAtGridEnd( currentIndex: Int, itemCount: Int, columnCount: Int ): Modifier { return onPreviewKeyEvent { event -> val nativeEvent = event.nativeKeyEvent if (nativeEvent.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) return@onPreviewKeyEvent false if (nativeEvent.action != KeyEvent.ACTION_DOWN && nativeEvent.action != KeyEvent.ACTION_UP) { return@onPreviewKeyEvent false } val hasNextRow = itemCount > 0 && currentIndex >= 0 && currentIndex + columnCount < itemCount !hasNextRow } } ================================================ FILE: app/tv/src/main/res/values/dimens.xml ================================================ 16dp 13dp ================================================ FILE: app/tv/src/main/res/values/strings.xml ================================================ 番剧放送时间表 个人收藏 已关注 正在追 历史记录 登录 日志列表 解码器信息 PGC 索引 遥控板按键演示 搜索输入 搜索结果 剧集信息 设置 网络测速 视频标签 现在不看 UP 投稿 用户信息 用户锁设置 用户切换 视频信息 视频播放 在列表区域按菜单键打开已关注UP列表 ================================================ FILE: app/tv/src/main/res/values/themes.xml ================================================ ================================================ FILE: bili-api/.gitignore ================================================ /build ================================================ FILE: bili-api/build.gradle.kts ================================================ plugins { alias(gradleLibs.plugins.google.ksp) alias(gradleLibs.plugins.kotlin.jvm) alias(gradleLibs.plugins.kotlin.serialization) } group = "dev.aaa1115910" dependencies { ksp(libs.koin.ksp.compiler) implementation(project(":bili-api:grpc")) implementation(libs.koin.core) implementation(libs.koin.annotations) implementation(libs.jsoup) implementation(libs.brotli) implementation(libs.kotlinx.coroutines) implementation(libs.kotlinx.serialization) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.core) implementation(libs.ktor.client.encoding) //implementation(libs.ktor.jsoup) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.serialization.kotlinx) implementation(libs.logging) implementation(libs.slf4j.simple) testImplementation(libs.kotlin.test) } tasks.test { useJUnitPlatform() } ================================================ FILE: bili-api/example-response/live-event/COMBO_SEND.json5 ================================================ { "cmd": "COMBO_SEND", "data": { "action": "投喂", "batch_combo_id": "batch:gift:combo_id:21486712:168598:31036:1669433933.0434", "batch_combo_num": 2, "combo_id": "gift:combo_id:21486712:168598:31036:1669433933.0427", "combo_num": 2, "combo_total_coin": 200, "dmscore": 56, "gift_id": 31036, "gift_name": "小花花", "gift_num": 0, "is_naming": false, "is_show": 1, "medal_info": { "anchor_roomid": 0, "anchor_uname": "", "guard_level": 0, "icon_id": 0, "is_lighted": 1, "medal_color": 9272486, "medal_color_border": 9272486, "medal_color_end": 9272486, "medal_color_start": 9272486, "medal_level": 11, "medal_name": "刺儿", "special": "", "target_id": 168598 }, "name_color": "", "r_uname": "逍遥散人", "ruid": 168598, "send_master": null, "total_num": 2, "uid": 21486712, "uname": "Ms星鸢" } } ================================================ FILE: bili-api/example-response/live-event/DANMU_MSG.json5 ================================================ { "cmd": "DANMU_MSG", "info": [ [ 0, 1, 25, 5816798, 1669371202340, -338894660, 0, "987fef1e", 0, 0, 0, "", 0, "{}", "{}", { "mode": 0, "show_player_type": 0, "extra": "{\"send_from_me\":false,\"mode\":0,\"color\":5816798,\"dm_type\":0,\"font_size\":25,\"player_mode\":1,\"show_player_type\":0,\"content\":\"遗物不卖有啥用?走文化吗?\",\"user_hash\":\"2558521118\",\"emoticon_unique\":\"\",\"bulge_display\":0,\"recommend_score\":1,\"main_state_dm_color\":\"\",\"objective_state_dm_color\":\"\",\"direction\":0,\"pk_direction\":0,\"quartet_direction\":0,\"anniversary_crowd\":0,\"yeah_space_type\":\"\",\"yeah_space_url\":\"\",\"jump_to_url\":\"\",\"space_type\":\"\",\"space_url\":\"\"}" }, { "activity_identity": "", "activity_source": 0, "not_show": 0 } ], //弹幕内容 "遗物不卖有啥用?走文化吗?", [ //mid 39344889, //用户名 "风的节奏_疾风", 0, 0, 0, 10000, 1, "" ], //粉丝勋章 未佩戴为[] [ 14, //粉丝勋章等级 "子轩", //粉丝勋章名称 "战术级子轩", 13355, 12478086, "", 0, 12478086, 12478086, 12478086, 0, 1, 3519797 ], [ 15, 0, 6406234, "\u003e50000", 0 ], [ "", "" ], 0, 0, null, { "ts": 1669371202, "ct": "347A3E92" }, 0, 0, null, null, 0, 42 ] } ================================================ FILE: bili-api/example-response/live-event/ENTRY_EFFECT.json5 ================================================ { "cmd": "ENTRY_EFFECT", "data": { "id": 4, "uid": 12342661, "target_id": 8739477, "mock_effect": 0, "face": "https://i1.hdslb.com/bfs/face/3d876e4fb959d0089e3c0299afae9fead128c70a.jpg", "privilege_type": 3, "copy_writing": "欢迎舰长 <%整点薯条吃吃吃%> 进入直播间", "copy_color": "#ffffff", "highlight_color": "#E6FF00", "priority": 1, "basemap_url": "https://i0.hdslb.com/bfs/live/mlive/11a6e8eb061c3e715d0a6a2ac0ddea2faa15c15e.png", "show_avatar": 1, "effective_time": 2, "web_basemap_url": "https://i0.hdslb.com/bfs/live/mlive/11a6e8eb061c3e715d0a6a2ac0ddea2faa15c15e.png", "web_effective_time": 2, "web_effect_close": 0, "web_close_time": 0, "business": 1, "copy_writing_v2": "欢迎舰长 <%整点薯条吃吃吃%> 进入直播间", "icon_list": [], "max_delay_time": 7, "trigger_time": 1669400294516525185, "identities": 6, "effect_silent_time": 0, "effective_time_new": 0, "web_dynamic_url_webp": "", "web_dynamic_url_apng": "", "mobile_dynamic_url_webp": "" } } ================================================ FILE: bili-api/example-response/live-event/GUARD_BUY.json5 ================================================ { "cmd": "GUARD_BUY", "data": { "uid": 6501027, "username": "玉紫", "guard_level": 3, "num": 1, "price": 198000, "gift_id": 10003, "gift_name": "舰长", "start_time": 1669435051, "end_time": 1669435051 } } ================================================ FILE: bili-api/example-response/live-event/HOT_RANK_CHANGED.json5 ================================================ { "cmd": "HOT_RANK_CHANGED", "data": { "rank": 8, "trend": 1, "countdown": 1795, "timestamp": 1669401005, "web_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=2&area_id=2&parent_area_id=2&second_area_id=0", "live_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=1&area_id=2&parent_area_id=2&second_area_id=0&is_live_half_webview=1&hybrid_rotate_d=1&hybrid_half_ui=1,3,100p,70p,ffffff,0,30,100,12,0;2,2,375,100p,ffffff,0,30,100,0,0;3,3,100p,70p,ffffff,0,30,100,12,0;4,2,375,100p,ffffff,0,30,100,0,0;5,3,100p,70p,ffffff,0,30,100,0,0;6,3,100p,70p,ffffff,0,30,100,0,0;7,3,100p,70p,ffffff,0,30,100,0,0;8,3,100p,70p,ffffff,0,30,100,0,0", "blink_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=3&area_id=2&parent_area_id=2&second_area_id=0&is_live_half_webview=1&hybrid_rotate_d=1&is_cling_player=1&hybrid_half_ui=1,3,100p,70p,ffffff,0,30,100,0,0;2,2,375,100p,ffffff,0,30,100,0,0;3,3,100p,70p,ffffff,0,30,100,0,0;4,2,375,100p,ffffff,0,30,100,0,0;5,3,100p,70p,ffffff,0,30,100,0,0;6,3,100p,70p,ffffff,0,30,100,0,0;7,3,100p,70p,ffffff,0,30,100,0,0;8,3,100p,70p,ffffff,0,30,100,0,0", "live_link_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=5&area_id=2&parent_area_id=2&second_area_id=0&is_live_half_webview=1&hybrid_rotate_d=1&is_cling_player=1&hybrid_half_ui=1,3,100p,70p,f4eefa,0,30,100,0,0;2,2,375,100p,f4eefa,0,30,100,0,0;3,3,100p,70p,f4eefa,0,30,100,0,0;4,2,375,100p,f4eefa,0,30,100,0,0;5,3,100p,70p,f4eefa,0,30,100,0,0;6,3,100p,70p,f4eefa,0,30,100,0,0;7,3,100p,70p,f4eefa,0,30,100,0,0;8,3,100p,70p,f4eefa,0,30,100,0,0", "pc_link_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=4&is_live_half_webview=1&area_id=2&parent_area_id=2&second_area_id=0&pc_ui=338,465,f4eefa,0", "icon": "https://i0.hdslb.com/bfs/live/65dbe013f7379c78fc50dfb2fd38d67f5e4895f9.png", "area_name": "网游", "rank_desc": "" } } ================================================ FILE: bili-api/example-response/live-event/HOT_RANK_CHANGED_V2.json5 ================================================ { "cmd": "HOT_RANK_CHANGED_V2", "data": { "rank": 15, "trend": 0, "countdown": 1140, "timestamp": 1669399860, "web_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=2&area_id=1&parent_area_id=1&second_area_id=21", "live_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=1&area_id=1&parent_area_id=1&second_area_id=21&is_live_half_webview=1&hybrid_rotate_d=1&hybrid_half_ui=1,3,100p,70p,ffffff,0,30,100,12,0;2,2,375,100p,ffffff,0,30,100,0,0;3,3,100p,70p,ffffff,0,30,100,12,0;4,2,375,100p,ffffff,0,30,100,0,0;5,3,100p,70p,ffffff,0,30,100,0,0;6,3,100p,70p,ffffff,0,30,100,0,0;7,3,100p,70p,ffffff,0,30,100,0,0;8,3,100p,70p,ffffff,0,30,100,0,0", "blink_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=3&area_id=1&parent_area_id=1&second_area_id=21&is_live_half_webview=1&hybrid_rotate_d=1&is_cling_player=1&hybrid_half_ui=1,3,100p,70p,ffffff,0,30,100,0,0;2,2,375,100p,ffffff,0,30,100,0,0;3,3,100p,70p,ffffff,0,30,100,0,0;4,2,375,100p,ffffff,0,30,100,0,0;5,3,100p,70p,ffffff,0,30,100,0,0;6,3,100p,70p,ffffff,0,30,100,0,0;7,3,100p,70p,ffffff,0,30,100,0,0;8,3,100p,70p,ffffff,0,30,100,0,0", "live_link_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=5&area_id=1&parent_area_id=1&second_area_id=21&is_live_half_webview=1&hybrid_rotate_d=1&is_cling_player=1&hybrid_half_ui=1,3,100p,70p,f4eefa,0,30,100,0,0;2,2,375,100p,f4eefa,0,30,100,0,0;3,3,100p,70p,f4eefa,0,30,100,0,0;4,2,375,100p,f4eefa,0,30,100,0,0;5,3,100p,70p,f4eefa,0,30,100,0,0;6,3,100p,70p,f4eefa,0,30,100,0,0;7,3,100p,70p,f4eefa,0,30,100,0,0;8,3,100p,70p,f4eefa,0,30,100,0,0", "pc_link_url": "https://live.bilibili.com/p/html/live-app-hotrank/index.html?clientType=4&is_live_half_webview=1&area_id=1&parent_area_id=1&second_area_id=21&pc_ui=338,465,f4eefa,0", "icon": "https://i0.hdslb.com/bfs/live/cb2e160ac4f562b347bb5ae6e635688ebc69580f.png", "area_name": "视频唱见", "rank_desc": "视频唱见top50" } } ================================================ FILE: bili-api/example-response/live-event/HOT_RANK_SETTLEMENT.json5 ================================================ { "cmd": "HOT_RANK_SETTLEMENT", "data": { "area_name": "手游", "cache_key": "c2f8a79cc9a709237fb65df23fd61025", "dm_msg": "恭喜主播 <% 逍遥散人 %> 荣登限时热门榜手游榜top9! 即将获得轮播资源位推荐哦!", "dmscore": 144, "face": "https://i1.hdslb.com/bfs/face/8a5de2d7486251e80307d8600cbf8649eb4035fe.jpg", "icon": "https://i0.hdslb.com/bfs/live/b4961bcfba56a26b69c35690dfcbdabbeb973c64.png", "rank": 9, "timestamp": 1669435204, "uname": "逍遥散人", "url": "https://live.bilibili.com/p/html/live-app-hotrank/result.html?is_live_half_webview=1&hybrid_half_ui=1,5,250,200,f4eefa,0,30,0,0,0;2,5,250,200,f4eefa,0,30,0,0,0;3,5,250,200,f4eefa,0,30,0,0,0;4,5,250,200,f4eefa,0,30,0,0,0;5,5,250,200,f4eefa,0,30,0,0,0;6,5,250,200,f4eefa,0,30,0,0,0;7,5,250,200,f4eefa,0,30,0,0,0;8,5,250,200,f4eefa,0,30,0,0,0&areaId=3&cache_key=c2f8a79cc9a709237fb65df23fd61025" } } ================================================ FILE: bili-api/example-response/live-event/HOT_RANK_SETTLEMENT_V2.json5 ================================================ { "cmd": "HOT_RANK_SETTLEMENT_V2", "data": { "rank": 7, "uname": "逍遥散人", "face": "https://i1.hdslb.com/bfs/face/8a5de2d7486251e80307d8600cbf8649eb4035fe.jpg", "timestamp": 1669434904, "icon": "https://i0.hdslb.com/bfs/live/cb2e160ac4f562b347bb5ae6e635688ebc69580f.png", "area_name": "原神", "url": "https://live.bilibili.com/p/html/live-app-hotrank/result.html?is_live_half_webview=1&hybrid_half_ui=1,5,250,200,f4eefa,0,30,0,0,0;2,5,250,200,f4eefa,0,30,0,0,0;3,5,250,200,f4eefa,0,30,0,0,0;4,5,250,200,f4eefa,0,30,0,0,0;5,5,250,200,f4eefa,0,30,0,0,0;6,5,250,200,f4eefa,0,30,0,0,0;7,5,250,200,f4eefa,0,30,0,0,0;8,5,250,200,f4eefa,0,30,0,0,0&areaId=321&cache_key=17276a47cc6fd1420feb0f2d86f60e85", "cache_key": "17276a47cc6fd1420feb0f2d86f60e85", "dm_msg": "恭喜主播 <% 逍遥散人 %> 荣登限时热门榜原神榜top7! 即将获得轮播资源位推荐哦!" } } ================================================ FILE: bili-api/example-response/live-event/HOT_ROOM_NOTIFY.json5 ================================================ { "cmd": "HOT_ROOM_NOTIFY", "data": { "threshold": 10000, "ttl": 300, "exit_no_refresh": 1, "random_delay_req_v2": [ { "path": "/live/getRoundPlayVideo", "delay": 10 }, { "path": "/xlive/web-room/v1/index/getOffLiveList", "delay": 120000 } ] } } ================================================ FILE: bili-api/example-response/live-event/INTERACT_WORD.json5 ================================================ { "cmd": "INTERACT_WORD", "data": { "contribution": { "grade": 0 }, "dmscore": 2, "fans_medal": { "anchor_roomid": 0, "guard_level": 0, "icon_id": 0, "is_lighted": 0, "medal_color": 0, "medal_color_border": 0, "medal_color_end": 0, "medal_color_start": 0, "medal_level": 0, "medal_name": "", "score": 0, "special": "", "target_id": 0 }, "identities": [ 1 ], "is_spread": 0, "msg_type": 1, "privilege_type": 0, "roomid": 21721813, "score": 1669399871533, "spread_desc": "", "spread_info": "", "tail_icon": 0, "timestamp": 1669399871, "trigger_time": 1669399870422980000, "uid": 268831673, "uname": "丸子泡汤ON", "uname_color": "" } } ================================================ FILE: bili-api/example-response/live-event/LIKE_INFO_V3_CLICK.json5 ================================================ { "cmd": "LIKE_INFO_V3_CLICK", "data": { "show_area": 0, "msg_type": 6, "like_icon": "https://i0.hdslb.com/bfs/live/23678e3d90402bea6a65251b3e728044c21b1f0f.png", "uid": 393552291, "like_text": "为主播点赞了", "uname": "有卢克迪丽歇斯", "uname_color": "", "identities": [ 1 ], "fans_medal": { "target_id": 0, "medal_level": 0, "medal_name": "", "medal_color": 0, "medal_color_start": 12632256, "medal_color_end": 12632256, "medal_color_border": 12632256, "is_lighted": 0, "guard_level": 0, "special": "", "icon_id": 0, "anchor_roomid": 0, "score": 0 }, "contribution_info": { "grade": 0 }, "dmscore": 20 } } ================================================ FILE: bili-api/example-response/live-event/LIKE_INFO_V3_UPDATE.json5 ================================================ { "cmd": "LIKE_INFO_V3_UPDATE", "data": { "click_count": 15805 } } ================================================ FILE: bili-api/example-response/live-event/LIVE_INTERACTIVE_GAME.json5 ================================================ { "cmd": "LIVE_INTERACTIVE_GAME", "data": { "type": 2, "uid": 2588066, "uname": "傻傻分不清噜", "uface": "", "gift_id": 0, "gift_name": "", "gift_num": 0, "price": 0, "paid": false, "msg": "这个debuff还行不影响", "fans_medal_level": 0, "guard_level": 0, "timestamp": 1669400655, "anchor_lottery": null, "pk_info": null, "anchor_info": null, "combo_info": null } } ================================================ FILE: bili-api/example-response/live-event/LIVE_MULTI_VIEW_CHANGE.json5 ================================================ { "cmd": "LIVE_MULTI_VIEW_CHANGE", "data": { "scatter": { "max": 120, "min": 5 } } } ================================================ FILE: bili-api/example-response/live-event/NOTICE_MSG.json5 ================================================ { "cmd": "NOTICE_MSG", "id": 712, "name": "地区争-任务100元", "full": { "head_icon": "https://i0.hdslb.com/bfs/live/ab106f494f4cc0c94fb78ed46144c72f6db000f6.webp", "tail_icon": "https://i0.hdslb.com/bfs/live/822da481fdaba986d738db5d8fd469ffa95a8fa1.webp", "head_icon_fa": "https://i0.hdslb.com/bfs/live/ab106f494f4cc0c94fb78ed46144c72f6db000f6.webp", "tail_icon_fa": "https://i0.hdslb.com/bfs/live/38cb2a9f1209b16c0f15162b0b553e3b28d9f16f.png", "head_icon_fan": 1, "tail_icon_fan": 4, "background": "#b6272b", "color": "#FFFFFFFF", "highlight": "#FDFF2FFF", "time": 15 }, "half": { "head_icon": "https://i0.hdslb.com/bfs/live/ab106f494f4cc0c94fb78ed46144c72f6db000f6.webp", "tail_icon": "", "background": "#b6272b", "color": "#FFFFFFFF", "highlight": "#FDFF2FFF", "time": 15 }, "side": { "head_icon": "", "background": "", "color": "", "highlight": "", "border": "" }, "roomid": 25906864, "real_roomid": 25906864, "msg_common": "恭喜<%酥软奶甜-满月快乐%>完成BLS年终决选赛任务,直播间发放价值100元的红包,快来抢鸭!", "msg_self": "恭喜<%酥软奶甜-满月快乐%>完成BLS年终决选赛任务,直播间发放价值100元的红包,快来抢鸭!", "link_url": "https://live.bilibili.com/25906864?broadcast_type=1&is_room_feed=1&from=28003&extra_jump_from=28003&live_lottery_type=1", "msg_type": 2, "shield_uid": -1, "business_id": "106", "scatter": { "min": 0, "max": 0 }, "marquee_id": "", "notice_type": 0 } ================================================ FILE: bili-api/example-response/live-event/ONLINE_RANK_COUNT.json5 ================================================ { "cmd": "ONLINE_RANK_COUNT", "data": { "count": 530 } } ================================================ FILE: bili-api/example-response/live-event/ONLINE_RANK_TOP3.json5 ================================================ { "cmd": "ONLINE_RANK_TOP3", "data": { "dmscore": 112, "list": [ { "msg": "恭喜 <%玉紫%> 成为高能用户", "rank": 2 } ] } } ================================================ FILE: bili-api/example-response/live-event/ONLINE_RANK_V2.json5 ================================================ { "cmd": "ONLINE_RANK_V2", "data": { "list": [ { "uid": 1306187864, "face": "https://i2.hdslb.com/bfs/face/e63f7ad010d8f3108bd737fd55c23e104217248a.jpg", "score": "1596", "uname": "台北蘑菇_宇", "rank": 1, "guard_level": 3 }, { "uid": 388212809, "face": "https://i2.hdslb.com/bfs/face/1c33c76b3d3eaef35d7c61e5af5f9bf6086278b1.jpg", "score": "1380", "uname": "香港的蘑菇", "rank": 2, "guard_level": 3 }, { "uid": 487390101, "face": "https://i0.hdslb.com/bfs/face/7cb9281552047a00a8160b48cda0914d69549477.jpg", "score": "1029", "uname": "冯提莫华北蘑菇屯", "rank": 3, "guard_level": 3 }, { "uid": 1584980458, "face": "https://i1.hdslb.com/bfs/face/93a1224413253d1889bfd53c44784547f65de83c.jpg", "score": "773", "uname": "ACE來了來了", "rank": 4, "guard_level": 3 }, { "uid": 1183714410, "face": "https://i0.hdslb.com/bfs/face/7260b8063591b0653e4b0022bdcb7bd31adb2444.jpg", "score": "713", "uname": "者我强是", "rank": 5, "guard_level": 2 }, { "uid": 489935300, "face": "https://i2.hdslb.com/bfs/face/0ad62efb289cce201410d80d1f06a9f35ebddc31.jpg", "score": "675", "uname": "改名就中奖1219frankzn", "rank": 6, "guard_level": 3 }, { "uid": 487621860, "face": "http://i0.hdslb.com/bfs/face/member/noface.jpg", "score": "670", "uname": "1219上海大飞哥", "rank": 7, "guard_level": 3 } ], "rank_type": "gold-rank" } } ================================================ FILE: bili-api/example-response/live-event/PREPARING.json5 ================================================ { "cmd": "PREPARING", "round": 1, "roomid": "47867" } ================================================ FILE: bili-api/example-response/live-event/ROOM_REAL_TIME_MESSAGE_UPDATE.json ================================================ { "cmd": "ROOM_REAL_TIME_MESSAGE_UPDATE", "data": { "roomid": 545068, "fans": 2567341, "red_notice": -1, "fans_club": 47278 } } ================================================ FILE: bili-api/example-response/live-event/SEND_GIFT.json ================================================ { "cmd": "SEND_GIFT", "data": { "action": "投喂", "batch_combo_id": "", "batch_combo_send": null, "beatId": "0", "biz_source": "Live", "blind_gift": null, "broadcast_id": 0, "coin_type": "silver", "combo_resources_id": 1, "combo_send": null, "combo_stay_time": 3, "combo_total_coin": 0, "crit_prob": 0, "demarcation": 1, "discount_price": 0, "dmscore": 36, "draw": 0, "effect": 0, "effect_block": 1, "face": "https://i1.hdslb.com/bfs/face/3ce3fb2429b61a6dc8017840c5885b2f051616f5.jpg", "face_effect_id": 0, "face_effect_type": 0, "float_sc_resource_id": 0, "giftId": 31531, "giftName": "PK票", "giftType": 5, "gold": 0, "guard_level": 0, "is_first": true, "is_naming": false, "is_special_batch": 0, "magnification": 1, "medal_info": { "anchor_roomid": 0, "anchor_uname": "", "guard_level": 0, "icon_id": 0, "is_lighted": 1, "medal_color": 13081892, "medal_color_border": 13081892, "medal_color_end": 13081892, "medal_color_start": 13081892, "medal_level": 18, "medal_name": "德云色", "special": "", "target_id": 8739477 }, "name_color": "", "num": 1, "original_gift_name": "", "price": 0, "rcost": 195044150, "remain": 0, "rnd": "1669400300110200001", "send_master": null, "silver": 0, "super": 0, "super_batch_gift_num": 0, "super_gift_num": 0, "svga_block": 0, "switch": true, "tag_image": "", "tid": "1669400300110200001", "timestamp": 1669400300, "top_list": null, "total_coin": 0, "uid": 396677032, "uname": "请看我的手势" } } ================================================ FILE: bili-api/example-response/live-event/STOP_LIVE_ROOM_LIST.json5 ================================================ { "cmd": "STOP_LIVE_ROOM_LIST", "data": { "room_id_list": [ 25234649, 4132568, 3512524, 11553, 186390, 26019554, 26409038, 26437196, 496410, 23664293, 24018668, 24435506, 3653682, 820635, 12844500, 26315109, 26508465, 9861086, 23326858, 24418, 24603846, 25681642, 25687765, 26142188, 342530, 21444308, 23128448, 23513218, 23670828, 25486341, 25899508, 25899728, 4000898, 4706818, 5289518, 5410386, 108938, 192514, 25366186, 25819126, 26074006, 26188146, 26314983, 26508485, 406536, 698159, 7076893, 11717546, 14327116, 14626370, 147220, 21643350, 2303390, 26348275, 538440, 14009080, 23724886, 25488820, 26070750, 4604384, 5365754, 8588940, 2059262, 22104325, 25739774, 25880724, 26318794, 5408045, 24366245, 24682275, 25003965, 26205655, 6943925, 14201557, 2053869, 24408727, 25675477, 26393557, 26462447, 5392287, 22325796, 23914996, 3412797, 23109143, 23686901, 25228924, 25430191, 26070601, 12016461, 12122431, 26448781, 9462351, 13238155, 21565087, 24378798, 24605638, 25065893, 25085673, 46113, 6453083, 11092055, 14904215, 24507653, 25343213, 25791048, 25927972, 4766474, 5256873, 9035643, 23319049, 23884290, 24056819, 25118469, 25694943, 26069139, 7111885, 821819, 23688869, 23903209, 24684949, 25283390, 25820389, 4849071, 7587769, 759969, 9074879, 25383592, 9226449, 23102605, 24326762, 268302, 6590962, 21344474, 24082829, 24351518, 24953254, 25513142, 26129578, 7317916, 7804949, 8907007, 23285947, 24791078, 25105073, 21890824, 22569396, 25180663, 4102740, 8631878, 23232517, 25063568, 14678054, 25794499 ] } } ================================================ FILE: bili-api/example-response/live-event/SUPER_CHAT_ENTRANCE.json5 ================================================ { "cmd": "SUPER_CHAT_ENTRANCE", "data": { "icon": "https://i0.hdslb.com/bfs/live/0a9ebd72c76e9cbede9547386dd453475d4af6fe.png", "jump_url": "https://live.bilibili.com/p/html/live-app-superchat2/index.html?is_live_half_webview=1&hybrid_half_ui=1,3,100p,70p,ffffff,0,30,100;2,2,375,100p,ffffff,0,30,100;3,3,100p,70p,ffffff,0,30,100;4,2,375,100p,ffffff,0,30,100;5,3,100p,60p,ffffff,0,30,100;6,3,100p,60p,ffffff,0,30,100;7,3,100p,60p,ffffff,0,30,100", "status": 0 } } ================================================ FILE: bili-api/example-response/live-event/SUPER_CHAT_MESSAGE.json5 ================================================ { "cmd": "SUPER_CHAT_MESSAGE", "data": { "background_bottom_color": "#2A60B2", "background_color": "#EDF5FF", "background_color_end": "#405D85", "background_color_start": "#3171D2", "background_icon": "", "background_image": "https://i0.hdslb.com/bfs/live/a712efa5c6ebc67bafbe8352d3e74b820a00c13e.png", "background_price_color": "#7497CD", "color_point": 0.7, "dmscore": 24, "end_time": 1669434178, "gift": { "gift_id": 12000, "gift_name": "醒目留言", "num": 1 }, "id": 5639015, "is_ranked": 1, "is_send_audit": 0, "medal_info": { "anchor_roomid": 21987615, "anchor_uname": "原神", "guard_level": 0, "icon_id": 0, "is_lighted": 0, "medal_color": "#5d7b9e", "medal_color_border": 12632256, "medal_color_end": 12632256, "medal_color_start": 12632256, "medal_level": 8, "medal_name": "粉丝团", "special": "", "target_id": 401742377 }, "message": "多喝热水(・∀・)", "message_font_color": "#A3F6FF", "message_trans": "", "price": 30, "rate": 1000, "start_time": 1669434118, "time": 60, "token": "72481621", "trans_mark": 0, "ts": 1669434118, "uid": 11437137, "user_info": { "face": "https://i0.hdslb.com/bfs/face/3d2b2e6afbf9aed3eccf7d00a7c02d2351b22cf3.jpg", "face_frame": "", "guard_level": 0, "is_main_vip": 0, "is_svip": 0, "is_vip": 0, "level_color": "#969696", "manager": 0, "name_color": "#666666", "title": "0", "uname": "Phain-", "user_level": 6 } }, "roomid": 1017 } ================================================ FILE: bili-api/example-response/live-event/SUPER_CHAT_MESSAGE_JPN.json5 ================================================ { "cmd": "SUPER_CHAT_MESSAGE_JPN", "data": { "id": "5639015", "uid": "11437137", "price": 30, "rate": 1000, "message": "多喝热水(・∀・)", "message_jpn": "", "is_ranked": 1, "background_image": "https://i0.hdslb.com/bfs/live/a712efa5c6ebc67bafbe8352d3e74b820a00c13e.png", "background_color": "#EDF5FF", "background_icon": "", "background_price_color": "#7497CD", "background_bottom_color": "#2A60B2", "ts": 1669434119, "token": "9555B0B6", "medal_info": { "icon_id": 0, "target_id": 401742377, "special": "", "anchor_uname": "原神", "anchor_roomid": 21987615, "medal_level": 8, "medal_name": "粉丝团", "medal_color": "#5d7b9e" }, "user_info": { "uname": "Phain-", "face": "https://i0.hdslb.com/bfs/face/3d2b2e6afbf9aed3eccf7d00a7c02d2351b22cf3.jpg", "face_frame": "", "guard_level": 0, "user_level": 6, "level_color": "#969696", "is_vip": 0, "is_svip": 0, "is_main_vip": 0, "title": "0", "manager": 0 }, "time": 59, "start_time": 1669434118, "end_time": 1669434178, "gift": { "num": 1, "gift_id": 12000, "gift_name": "醒目留言" } }, "roomid": "1017" } ================================================ FILE: bili-api/example-response/live-event/USER_TOAST_MSG.json5 ================================================ { "cmd": "USER_TOAST_MSG", "data": { "anchor_show": true, "color": "#00D1F1", "dmscore": 90, "effect_id": 397, "end_time": 1669435051, "face_effect_id": 44, "gift_id": 10003, "guard_level": 3, "is_show": 0, "num": 1, "op_type": 3, "payflow_id": "2211261157000842110278874", "price": 138000, "role_name": "舰长", "room_effect_id": 590, "start_time": 1669435051, "svga_block": 0, "target_guard_count": 972, "toast_msg": "<%玉紫%> 续费了舰长,今天是TA陪伴主播的第691天", "uid": 6501027, "unit": "月", "user_show": true, "username": "玉紫" } } ================================================ FILE: bili-api/example-response/live-event/WATCHED_CHANGE.json5 ================================================ { "cmd": "WATCHED_CHANGE", "data": { "num": 305504, "text_small": "30.5万", "text_large": "30.5万人看过" } } ================================================ FILE: bili-api/example-response/live-event/WIDGET_BANNER.json5 ================================================ { "cmd": "WIDGET_BANNER", "data": { "timestamp": 1669400283, "widget_list": { "280": { "id": 280, "title": "BLS年终赛·地区争锋赛", "cover": "", "web_cover": "", "tip_text": "地区争锋赛", "tip_text_color": "#FFFFFF", "tip_bottom_color": "#3F2D25", "jump_url": "https://live.bilibili.com/activity/live-activity-battle/index.html?app_name=bls_winter_2022&is_live_half_webview=1&hybrid_rotate_d=1&hybrid_half_ui=1,3,100p,70p,0,0,0,0,12,0;2,2,375,100p,0,0,0,0,12,0;3,3,100p,70p,0,0,0,0,12,0;4,2,375,100p,0,0,0,0,12,0;5,3,100p,70p,0,0,0,0,12,0;6,3,100p,70p,0,0,0,0,12,0;7,3,100p,70p,0,0,0,0,12,0;8,3,100p,70p,0,0,0,0,12,0&room_id=545068&uid=8739477#/area", "url": "", "stay_time": 5, "site": 1, "platform_in": [ "live", "blink", "live_link", "web", "pc_link" ], "type": 1, "band_id": 101318, "sub_key": "", "sub_data": "%7B%22act_status%22%3A1%2C%22stage%22%3A1%2C%22text%22%3A%22%22%2C%22stage_1%22%3A%7B%22current_value%22%3A4923%2C%22target_value%22%3A50000%2C%22current_level%22%3A5%2C%22is_all_complete%22%3A2%7D%2C%22stage_2%22%3A%7B%22settlement%22%3A0%2C%22rank%22%3A0%2C%22score%22%3A0%2C%22rank_type%22%3A0%2C%22rank_distance_score%22%3A0%7D%2C%22stage_3%22%3A%7B%22settlement%22%3A0%2C%22show_type%22%3A0%2C%22rank_all%22%3A0%2C%22rank%22%3A0%2C%22score%22%3A0%2C%22rank_type%22%3A0%2C%22rank_distance_score%22%3A0%7D%2C%22stage_4%22%3A%7B%22settlement%22%3A0%2C%22rank%22%3A0%2C%22score%22%3A0%2C%22rank_type%22%3A0%2C%22rank_distance_score%22%3A0%2C%22team_name%22%3A%22%22%2C%22anchor_img%22%3A%22%22%7D%2C%22notice%22%3A%7B%22type%22%3A0%2C%22sub_type%22%3A0%2C%22number%22%3A%22%22%2C%22is_get%22%3A0%2C%22rank%22%3A0%2C%22pond_pk_amount%22%3A0%2C%22rank_distance_score%22%3A0%2C%22red_packet_multiple%22%3A0%2C%22red_packet_amount%22%3A0%2C%22time%22%3A0%2C%22timeout%22%3A0%2C%22level%22%3A0%7D%7D", "is_add": true } } } } ================================================ FILE: bili-api/grpc/.gitignore ================================================ /build ================================================ FILE: bili-api/grpc/build.gradle.kts ================================================ import com.google.protobuf.gradle.proto plugins { //id("java-library") alias(gradleLibs.plugins.google.protobuf) alias(gradleLibs.plugins.kotlin.jvm) } dependencies { api(libs.grpc.kotlin.stub) api(libs.grpc.okhttp) api(libs.grpc.protobuf) api(libs.grpc.stub) api(libs.protobuf.kotlin) implementation(libs.kotlinx.coroutines) } sourceSets["main"].proto { srcDir("./proto") ProtobufConfiguration.excludeProtoFiles.forEach(::exclude) } protobuf { protoc { artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}" } plugins { create("java") { artifact = "io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.asProvider().get()}" } create("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.asProvider().get()}" } create("grpckt") { artifact = "io.grpc:protoc-gen-grpc-kotlin:${libs.versions.grpc.kotlin.get()}:jdk8@jar" } } generateProtoTasks { all().forEach { it.builtins { named("java") { //option("lite") } create("kotlin") { //option("lite") } } it.plugins { create("grpc") { //option("lite") } create("grpckt") { //option("lite") } } } } } ================================================ FILE: bili-api/grpc/proto/bilibili/account/fission/v1/fission.proto ================================================ syntax = "proto3"; package bilibili.account.fission.v1; option java_multiple_files = true; // Fission裂变 service Fission { // 活动入口 rpc Entrance (EntranceReq) returns (EntranceReply); // 首页弹窗 rpc Window (WindowReq) returns (WindowReply); // rpc Privacy (PrivacyReq) returns (PrivacyReply); } // 动画效果 message AnimateIcon { // icon文件 string icon = 1; // 动效json文件 string json = 2; } // 活动入口-响应 message EntranceReply { // 展示图标 string icon = 1; // 活动名称 string name = 2; // 活动跳转链接 string url = 3; // 动画效果 AnimateIcon animate_icon = 4; } // 活动入口-请求 message EntranceReq {} // message PrivacyReply { // string message = 1; } // message PrivacyReq { // string activity_uid = 1; } //首页弹窗-响应 message WindowReply { // 弹窗类型 // 0:弹窗 1:普通页面 int32 type = 1; // 跳转链接 string url = 2; // 上报数据字段 string report_data = 3; } // 首页弹窗-请求 message WindowReq { } ================================================ FILE: bili-api/grpc/proto/bilibili/ad/v1/ad.proto ================================================ syntax = "proto3"; package bilibili.ad.v1; option java_multiple_files = true; import "google/protobuf/any.proto"; import "google/protobuf/wrappers.proto"; // 自动播放视频 message AdAutoPlayVideoDto { // avid int64 avid = 1; // cid int64 cid = 2; // 分P int64 page = 3; // string from = 4; // 是否自动播放 string url = 5; // 是否自动播放 string cover = 6; // 是否自动播放 bool auto_play = 7; // 按钮是否动态变色 bool btn_dyc_color = 8; // 按钮动态变色时间 ms int32 btn_dyc_time = 9; // 用于做联播是否是同一个视频的id int64 biz_id = 10; // 开始播放三方监控 repeated string process0_urls = 11; // 播放3S三方监控 repeated string play_3s_urls = 12; // 播放5S三方监控 repeated string play_5s_urls = 13; // 横竖屏 int32 orientation = 14; } // 商业标信息 message AdBusinessMarkDto { // 商业标样式 // 0:不展示标 1:实心+文字 2:空心框+文字 3:纯文字标 4:纯图片标 int32 type = 1; // 商业标文案 string text = 2; // 商业标文案颜色,如#80FFFFFF RGBA string text_color = 3; // 夜间模式文字色 string text_color_night = 4; // 背景色 string bg_color = 5; // 夜间模式背景色 string bg_color_night = 6; // 边框色 string border_color = 7; // 夜间模式边框色 string border_color_night = 8; // 图片商业标 string img_url = 9; // 图片高度 int32 img_height = 10; // 图片宽度 int32 img_width = 11; // string bg_border_color = 12; } // 按钮 message AdButtonDto { // 类型 // 1:落地页 2:应用唤起 3:应用下载 int32 type = 1; // 按钮文案 string text = 2; // 按钮跳转地址 string jump_url = 3; // 跳转监测链接 string report_urls = 4; // 唤起schema string dlsuc_callup_url = 5; // 游戏id int32 game_id = 6; // 游戏监控字段 string game_monitor_param = 7; // int32 game_channel_id = 8; // string game_channel_extra = 9; } // 卡片 message AdCardDto { // 卡片类型 int32 card_type = 1; // 标题 string title = 2; // 描述 string desc = 3; // 额外描述 string extra_desc = 4; // 长描述 string long_desc = 5; // 短标题, 弹幕广告目录面板标题 string short_title = 6; // 弹幕/浮层广告的弹幕标题 string danmu_title = 7; // 弹幕/浮层广告的弹幕高度,整型,分母为100 int32 danmu_height = 8; // 弹幕/浮层广告的弹幕宽度,整型,分母为100 int32 danmu_width = 9; // 弹幕/浮层广告生存时间,单位为毫秒 int32 danmu_life = 10; // 弹幕/浮层开始时间,单位为毫秒 int32 danmu_begin = 11; // 背景色值(含透明度)如#80FFFFFF string danmu_color = 12; // 弹幕/浮层广告H5落地页 string danmu_h5url = 13; // 弹幕/浮层 广告icon string danmu_icon = 14; // 折叠时间,永驻浮层折叠时间,单位为毫秒 int32 fold_time = 15; // 广告标文案 string ad_tag = 16; // cover数组 repeated AdCoverDto covers = 17; // 卡片跳转链接 string jump_url = 18; // string imax_landing_page_json_string = 19; // app唤起schema string callup_url = 20; // univeral link域名 string universal_app = 21; // 原价, 单位为分 string ori_price = 22; // 现价, 同上 int32 cur_price = 23; // 券后/现价 价格描述 string price_desc = 24; // 价格单位符号 string price_symbol = 25; // 券后价格 "1000" string goods_cur_price = 26; // 原价 "¥1002" string goods_ori_price = 27; // 开放平台商品 AdGoodDto good = 28; // 打分? 满分为100 int32 rank = 29; // 热度 int32 hot_score = 30; // 按钮 AdButtonDto button = 31; // 广告主logo string adver_logo = 32; // 广告主name string adver_name = 33; // 广告主主页链接 string adver_page_url = 34; // 视频弹幕,视频广告用 repeated string video_barrage = 35; // 商业标信息 AdBusinessMarkDto ad_tag_style = 36; // 自动播放视频 AdAutoPlayVideoDto video = 37; // 反馈面板功能模块,屏蔽、投诉、广告介绍 AdFeedbackPanelDto feedback_panel = 38; // int64 adver_mid = 39; // int64 adver_account_id = 40; // string duration = 41; // repeated QualityInfo quality_infos = 42; // 动态广告文本 string dynamic_text = 43; // 广告主信息 AdverDto adver = 44; // 评分 int32 grade_level = 45; // bool support_transition = 46; // string transition = 47; // int32 under_player_interaction_style = 48; // string imax_landing_page_v2 = 49; // SubCardModule subcard_module = 50; // int32 grade_denominator = 51; // int32 star_level = 52; // Bulletin bulletin = 53; // Gift gift = 54; // repeated string game_tags = 55; // int32 ori_mark_hidden = 56; // bool use_multi_cover = 57; // WxProgramInfo wx_program_info = 58; // AndroidGamePageRes android_game_page_res = 59; // NotClickableArea not_clickable_area = 60; // ForwardReply forward_reply = 61; } // 额外广告数据 message AdContentExtraDto { // 动态布局 string layout = 1; // 展现监控url repeated string show_urls = 2; // 点击监控url repeated string click_urls = 3; // 弹幕创意列表展示第三方上报 repeated string danmu_list_show_urls = 4; // 弹幕创意列表点击第三方上报 repeated string danmu_list_click_urls = 5; // 弹幕详情页展示第三方上报 repeated string danmu_detail_show_urls = 6; // 弹幕商品添加购物车第三方上报 repeated string danmu_trolley_add_urls = 7; // useWebView默认false bool use_ad_web_v2 = 8; // app唤起白名单 repeated string open_whitelist = 9; // app下载白名单 AppPackageDto download_whitelist = 10; // 卡片相关信息 AdCardDto card = 11; // 视频播放和弹幕播放上报控制时间 ms int32 report_time = 12; // 是否优先唤起app store int32 appstore_priority = 13; // 广告售卖类型 int32 sales_type = 14; // 落地页是否预加载 int32 preload_landingpage = 15; // 是否需要展示风险行业提示 bool special_industry = 16; // 风险行业提示 string special_industry_tips = 17; // 是否展示下载弹框 bool enable_download_dialog = 18; // 是否允许分享 bool enable_share = 19; // 个人空间广告入口类型 // 1:橱窗 2:商品店铺 3:小程序 int32 upzone_entrance_type = 20; // 个人空间广告入口上报id,橱窗id(当前用Mid)、店铺id或者小程序id int32 upzone_entrance_report_id = 21; // 分享数据 AdShareInfoDto share_info = 22; // topview图片链接,闪屏预下载用 string topview_pic_url = 23; // topview视频链接,闪屏预下载用 string topview_video_url = 24; // 点击区域 // 0:表示banner可点击 1:表示素材可点击 int32 click_area = 25; // 店铺 int64 shop_id = 26; // up主 int64 up_mid = 27; // 回传id string track_id = 28; // 商店直投 int32 enable_store_direct_launch = 29; // DPA2.0商品ID int64 product_id = 30; // bool enable_double_jump = 31; // repeated string show1s_urls = 32; // string from_track_id = 33; // bool store_callup_card = 34; // int32 landingpage_download_style = 35; // int32 special_industry_style = 36; // bool enable_h5_alert = 37; // int32 macro_replace_priority = 38; // int32 feedback_panel_style = 39; // string appstore_url = 40; // int32 enable_h5_pre_load = 41; // string h5_pre_load_url = 42; // string cm_from_track_id = 43; } // 广告卡片封面数据 message AdCoverDto { // 图片链接 string url = 1; // 动图循环次数 // 0:无限循环 int32 loop = 2; // 图片点击跳转地址,截至目前为空 string jump_url = 3; // 跳转监测链接, 数组,单个图片的监控,出区别于click_urls,应前端要求。(此字段截至目前为空,使用时需再次确认) repeated string report_urls = 4; // 图片高度 int32 image_height = 5; // 图片宽度 int32 image_width = 6; } // 广告内容 message AdDto { // 广告创意ID int64 creative_id = 1; // 广告闭环上报回传数据 string ad_cb = 2; // 额外广告数据 AdContentExtraDto extra = 3; // 广告标记 int32 cm_mark = 4; // int64 top_view_id = 5; // int32 creative_type = 6; // int32 card_type = 7; // int32 creative_style = 8; // int32 is_ad = 9; // CreativeDto creative_content = 10; } // 反馈面板功能模块 message AdFeedbackPanelDto { // 面板类型,广告、推广 string panel_type_text = 1; // 反馈面版信息 repeated AdFeedbackPanelModuleDto feedback_panel_detail = 2; // string toast = 3; // string open_rec_tips = 4; // string close_rec_tips = 5; } // 反馈面版信息 message AdFeedbackPanelModuleDto { // 模块id int32 module_id = 1; // icon url string icon_url = 2; // 跳转类型 // 1:气泡 2:H5 int32 jump_type = 3; // 跳转地址 string jump_url = 4; // 文案 string text = 5; // 二级文案数组 repeated AdSecondFeedbackPanelDto secondary_panel = 6; // string sub_text = 7; } // 开放平台商品 message AdGoodDto { // 电商商品ID int64 item_id = 1; // 电商SKU ID int64 sku_id = 2; // 店铺ID int64 shop_id = 3; // SKU库存 int64 sku_num = 4; } // 有弹幕的ogv ep message AdOgvEpDto { // 分集epid int64 epid = 1; // 是否显示 "荐" bool has_recommend = 2; } // 广告控制 message AdsControlDto { // 视频是否有弹幕,如有,需请求弹幕广告 int32 has_danmu = 1; // 有弹幕的分P视频的cid,已弃用 repeated int64 cids = 2; // 有弹幕的ogv ep repeated AdOgvEpDto eps = 3; } // 二级文案 message AdSecondFeedbackPanelDto { // 屏蔽理由id int32 reason_id = 1; // 理由文案 string text = 2; } // 分享 message AdShareInfoDto { // 分享标题 string title = 1; // 分享副标题 string subtitle = 2; // 分享图片url string image_url = 3; } // 广告主信息 message AdverDto { // int64 adver_id = 1; // string adver_logo = 2; // string adver_name = 3; // int32 adver_type = 4; // string adver_page_url = 5; // string adver_desc = 6; } // message AndroidGamePageRes { // Module1 module1 = 1; // Module3 module3 = 2; // Module4 module4 = 3; // Module5 module5 = 4; // Module6 module6 = 5; // Module7 module7 = 6; // Module8 module8 = 7; // Module9 module9 = 8; // Module10 module10 = 9; // Module11 module11 = 10; // Module12 module12 = 11; // Module13 module13 = 12; // repeated int32 module_seq = 13; // string background_color = 14; // Module14 module14 = 15; } // message AndroidTag { // string text = 1; // int32 type = 2; } // app下载白名单 message AppPackageDto { // 包大小(单位bytes) int64 size = 1; // string display_name = 2; // string apk_name = 3; // url string url = 4; // bili schema url string bili_url = 5; // 包md5 string md5 = 6; // 包icon string icon = 7; // 开发者姓名 string dev_name = 8; // 权限地址 string auth_url = 9; // 权限名,逗号隔开 string auth_name = 10; // 版本 string version = 11; // 更新时间,yy-mm-hh格式 string update_time = 12; // 隐私协议标题 string privacy_name = 13; // 隐私协议url string privacy_url = 14; } // message Bulletin { // string tag_text = 1; // string text = 2; } // message Comment { // int64 game_base_id = 1; // string user_name = 2; // string user_face = 3; // int32 user_level = 4; // string comment_no = 5; // int32 grade = 6; // string content = 7; // int32 up_count = 8; } // message CreativeDto { // string title = 1; // string description = 2; // string image_url = 3; // string image_md5 = 4; // string url = 5; // string click_url = 6; // string show_url = 7; // int64 video_id = 8; // string thumbnail_url = 9; // string thumbnail_url_md5 = 10; // string logo_url = 11; // string logo_md5 = 12; // string username = 13; } // message CustomPlayUrl { // int32 play_time = 1; // repeated string urls = 2; } // message ForwardReply { // int64 comment_id = 1; // string message = 2; // string highlight_text = 3; // string highlight_prefix_icon = 4; // string callup_url = 5; // string jump_url = 6; // int32 jump_type = 7; // string author_name = 8; // string author_icon = 9; } // message Gift { // string icon = 1; // string night_icon = 2; // string text = 3; // string url = 4; } // message IosGamePageRes { // string logo = 1; // string name = 2; // string sub_titile = 3; // repeated string image_url = 4; // string desc = 5; // AdButtonDto game_button = 6; // double grade = 7; // string rank_num = 8; // string rank_name = 9; } // message Module1 { // string game_name = 1; // string game_icon = 2; // string developer_input_name = 3; // repeated AndroidTag tag_list = 4; } // message Module3 { // bool display = 1; // repeated QualityParmas quality_params = 3; } // message Module4 { // bool display = 1; // int32 gift_num = 2; // string gift_name = 3; // int32 gift_icon_num = 4; // repeated string icon_urls = 5; } // message Module5 { // bool display = 1; // string game_summary = 2; } // message Module6 { // bool display = 1; // string game_desc = 2; } // message Module7 { // bool display = 1; // repeated ScreenShots screen_shots = 2; } // message Module8 { // bool display = 1; // repeated string tag_list = 2; } // message Module9 { // bool display = 1; // string dev_introduction = 2; } // message Module10 { // bool display = 1; // string latest_update = 2; } // message Module11 { // bool display = 1; // repeated int32 star_number_list = 2; // string comment_str = 3; // double grade = 4; } // message Module12 { // bool display = 1; // repeated Comment comment_list = 2; // string comment_num = 3; // bool show_all_comment = 4; } // message Module13 { // int64 pkg_size = 1; // string customer_service = 2; // string website = 3; // string authority = 4; // string privacy = 5; // string developer_name = 6; // string update_time = 7; // string game_version = 8; // string android_pkg_name = 9; } // message Module14 { // repeated Reward reward_list = 1; // bool display = 2; } // message NotClickableArea { // int32 x = 1; // int32 y = 2; // int32 z = 3; } // message QualityInfo { // string icon = 1; // string text = 2; // bool is_bg = 3; // string bg_color = 4; // string bg_color_night = 5; } // message QualityParmas { // string first_line = 1; // string second_line = 2; // double grade = 3; // string rank_icon = 4; // int32 quality_type = 5; } // message Reward { // int32 level = 1; // string title = 2; // string content = 3; // string pic = 4; // bool reach = 5; } // message ScreenShots { // string url = 1; // int32 height = 2; // int32 width = 3; // int32 seq = 4; } // 广告数据 message SourceContentDto { // 广告请求id string request_id = 1; // 广告资源位source ID int32 source_id = 2; // 广告资源位resource ID int32 resource_id = 3; // 广告位上报标记,对广告返回数据恒为true bool is_ad_loc = 4; // 与天马现有逻辑一致, 0有含义 // 0:内容 1:广告 google.protobuf.Int32Value server_type = 5; // 客户端IP回传拼接 string client_ip = 6; // 广告卡片位置在一刷中的位置, 天马用, 0有含义 google.protobuf.Int32Value card_index = 7; // 广告资源位source 位次 int32 index = 8; // 广告内容 AdDto ad_content = 9; } // message SubCardModule { // string subcard_type = 1; // string icon = 2; // string desc = 3; // string rank_stars = 4; // string amount_number = 5; // string avatar = 6; // string title = 7; // AdButtonDto button = 8; // repeated TagInfo tag_infos = 9; } // message Tab2ExtraDto { // string cover_url = 1; // string title = 2; // string desc = 3; // AdButtonDto button = 5; // int32 auto_animate_time_ms = 6; // bool enable_click = 7; // string panel_url = 8; // repeated AppPackageDto download_whitelist = 9; // repeated string open_whitelist = 10; // bool use_ad_web_v2 = 11; // bool enable_store_direct_launch = 12; // int32 sales_type = 13; // int32 landingpage_download_style = 15; // int32 appstore_priority = 16; // string appstore_url = 17; // int32 appstore_delay_time = 18; // int32 page_cover_type = 19; // int32 page_pull_type = 20; // AndroidGamePageRes android_game_page_res = 21; // IosGamePageRes ios_game_page_res = 22; // AdBusinessMarkDto ad_tag_style = 23; // AdFeedbackPanelDto feedback_panel = 24; // string ad_cb = 25; // int32 url_type = 26; } // message TabExtraDto { // string tab_url = 1; // int32 enable_store_direct_launch = 2; // int32 store_callup_card = 3; // int32 sales_type = 4; // repeated AppPackageDto download_whitelist = 5; // bool special_industry = 6; // string special_industry_tips = 7; // repeated string open_whitelist = 8; // int32 landingpage_download_style = 9; // int32 appstore_priority = 10; // bool use_ad_web_v2 = 11; // bool enable_download_dialog = 12; // string appstore_url = 13; // int32 appstore_delay_time = 14; } // message TabInfoDto { // string tab_name = 1; // google.protobuf.Any extra = 2; // int32 tab_version = 3; } // message TagInfo { // string text = 1; // string text_color = 2; // string text_color_night = 3; // string bg_color = 4; // string bg_color_night = 5; // string border_color = 6; // string border_color_night = 7; // string type = 8; } // message WxProgramInfo { // string org_id = 1; // string name = 2; // string path = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/api/player/v1/player.proto ================================================ syntax = "proto3"; package bilibili.api.player.v1; option java_multiple_files = true; // 心跳上报 service Heartbeat { // 客户端心跳上报 rpc Mobile(HeartbeatReq) returns (HeartbeatReply); } // 客户端心跳上报-响应 message HeartbeatReply { // 时间戳 int64 ts = 1; } // 客户端心跳上报-请求 message HeartbeatReq { // int64 server_time = 1; // string session = 2; // 用户 mid int64 mid = 3; // 稿件 avid int64 aid = 4; // 视频 cid int64 cid = 5; // string sid = 6; // int64 epid = 7; // string type = 8; // int32 sub_type = 9; // int32 quality = 10; // int64 total_time = 11; // int64 paused_time = 12; // int64 played_time = 13; // int64 video_duration = 14; // string play_type = 15; // int32 network_type = 16; // int64 last_play_progress_time = 17; // int64 max_play_progress_time = 18; // int32 from = 19; // string from_spmid = 20; // string spmid = 21; // string epid_status = 22; // string play_status = 23; // string user_status = 24; // int64 actual_played_time = 25; // int32 auto_play = 26; // int64 list_play_time = 27; // int64 detail_play_time = 28; } ================================================ FILE: bili-api/grpc/proto/bilibili/api/probe/v1/probe.proto ================================================ syntax = "proto3"; package bilibili.api.probe.v1; option java_multiple_files = true; // 服务可用性探针 service Probe { // rpc TestCode (CodeReq) returns (CodeReply); // rpc TestReq (ProbeReq) returns (ProbeReply); // rpc TestStream (ProbeStreamReq) returns (ProbeStreamReply); // rpc TestSub (ProbeSubReq) returns (ProbeSubReply); } // 服务可用性探针 service ProbeService { // rpc Echo(SimpleMessage) returns (SimpleMessage); // rpc EchoBody(SimpleMessage) returns (SimpleMessage); // rpc EchoDelete(SimpleMessage) returns (SimpleMessage); // rpc EchoError(ErrorMessage) returns (ErrorMessage); // rpc EchoPatch(DynamicMessageUpdate) returns (DynamicMessageUpdate); } // enum Category { CATEGORY_UNSPECIFIED = 0; // CATEGORY_ONE = 1; // CATEGORY_TWO = 2; // CATEGORY_THREE = 3; // CATEGORY_FOUR = 4; // } // message CodeReply { // string id = 1; // string id1 = 2; // int64 code = 3; // string message_s = 4; } // message CodeReq { // int64 code = 1; } // message CreateTopic { // int64 id = 1; } // message CreatTask { // string task = 1; } // message DynamicMessageUpdate { // SimpleMessage body = 1; } // message Embedded { // bool bool_val = 1; // int32 int32_val = 2; // int64 int64_val = 3; // float float_val = 4; // double double_val = 5; // string string_val = 6; // repeated bool repeated_bool_val = 7; // repeated int32 repeated_int32_val = 8; // repeated int64 repeated_int64_val = 9; // repeated float repeated_float_val = 10; // repeated double repeated_double_val = 11; // repeated string repeated_string_val = 12; // map map_string_val = 13; // map map_error_val = 14; } // message ErrorMessage { // int64 code = 1; // string reason = 2; // string message = 3; } // Deprecated enum ErrorReason { PROBE_UNSPECIFIED = 0; // PROBE_CATEGORY_NOTFOUND = 1; // } // message ProbeReply { // string content = 1; // int64 timestamp = 2; } // message ProbeReq { // int64 mid = 1; // string buvid = 2; } // message ProbeStreamReply { // int64 sequence = 1; // int64 timestamp = 2; // string content = 3; } // message ProbeStreamReq { // int64 mid = 1; // int64 sequence = 2; } // message ProbeSubReply { // int64 message_id = 1; } // message ProbeSubReq { // string buvid = 1; } // message SimpleMessage { // int32 id = 1; // int64 num = 2; // string lang = 3; // int32 cate = 4; // Embedded embedded = 5; } // message Task { // string name = 1; // string author = 2; // bool cache = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/api/ticket/v1/ticket.proto ================================================ syntax = "proto3"; package bilibili.api.ticket.v1; option java_multiple_files = true; service Ticket { // 获取鉴权用 Ticket rpc GetTicket (GetTicketRequest) returns (GetTicketResponse); } // message GetTicketRequest { // 包含: // + x-fingerprint(包含手机各类信息, 使用 datacenter.hakase.protobuf.AndroidDeviceInfo 生成) // + x-exbadbasket(?) map context = 1; // 暂时固定为 ec01 string key_id = 2; // bytes sign = 3; // 暂时留空 string token = 4; } // message GetTicketResponse { // message Context { // string v_voucher = 1; } // x-bili-ticket string ticket = 1; // 有效期起 int64 created_at = 2; // 有效期 int64 ttl = 3; // Context context = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/archive/middleware/v1/preload.proto ================================================ syntax = "proto3"; package bilibili.app.archive.middleware.v1; option java_multiple_files = true; // 视频秒开参数 message PlayerArgs { // 清晰度 int64 qn = 1; // 流版本 int64 fnver = 2; // 流类型 int64 fnval = 3; // 返回url是否强制使用域名 // 0:不强制使用域名 1:http域名 2:https域名 int64 force_host = 4; // 音量均衡 int64 voice_balance = 5; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/archive/v1/archive.proto ================================================ syntax = "proto3"; package bilibili.app.archive.v1; option java_multiple_files = true; // 稿件基本信息 message Arc { // 稿件avid int64 aid = 1; // 稿件分P数 int64 videos = 2; // 分区id int32 type_id = 3; // 二级分区名 string type_name = 4; // 稿件类型 // 1:原创 2:转载 int32 copyright = 5; // 稿件封面url string pic = 6; // 稿件标题 string title = 7; // 稿件发布时间 int64 pubdate = 8; // 用户投稿时间 int64 ctime = 9; // 稿件简介 string desc = 10; // 稿件状态 int32 state = 11; // 访问属性 // 0:全部可见 10000:登录可见 int32 access = 12; // 属性位配置(现在无了) int32 attribute = 13; // 空 string tag = 14; // 空 repeated string tags = 15; // 稿件总时长(单位为秒) int64 duration = 16; // 参与的活动id int64 mission_id = 17; // 绑定的商单id int64 order_id = 18; // PGC稿件强制重定向url(如番剧、影视) string redirect_url = 19; // 空 int64 forward = 20; // 控制标志 Rights rights = 21; // UP主信息 Author author = 22; // 状态数 Stat stat = 23; // 空 string report_result = 24; // 投稿时发送的动态内容 string dynamic = 25; // 稿件1P cid int64 first_cid = 26; // 稿件1P 分辨率 Dimension dimension = 27; // 合作组成员列表 repeated StaffInfo staff_info = 28; // UGC合集id int64 season_id = 29; // 新版属性位配置(也没用) int64 attribute_v2 = 30; // SeasonTheme season_theme = 31; // string short_link_v2 = 40; // int32 up_from_v2 = 41; // string first_frame = 42; } // UP主信息 message Author { // UP主mid int64 mid = 1; // UP主昵称 string name = 2; // UP主头像url string face = 3; } // 分辨率 message Dimension { // 宽度 int64 width = 1; // 高度 int64 height = 2; // 方向 // 0:横屏 1:竖屏 int64 rotate = 3; } // 分P信息 message Page { // 视频cid int64 cid = 1; // 分P序号 int32 page = 2; // 源类型 // vupload:B站 qq:腾讯 hunan:芒果 string from = 3; // 分P标题 string part = 4; // 分P时长(单位为秒) int64 duration = 5; // 外链vid string vid = 6; // 分P简介 string desc = 7; // 外链url string webLink = 8; // 分P分辨率 Dimension dimension = 9; // string first_frame = 10; } // 稿件控制标志 message Rights { // 老版是否付费 int32 bp = 1; // 允许充电 int32 elec = 2; // 允许下载 int32 download = 3; // 是否电影 int32 movie = 4; // PGC稿件需要付费 int32 pay = 5; // 是否高码率 int32 hd5 = 6; // 是否显示“禁止转载”标志 int32 no_reprint = 7; // 是否允许自动播放 int32 autoplay = 8; // UGC稿件需要付费(旧版) int32 ugc_pay = 9; // 是否联合投稿 int32 is_cooperation = 10; // 是否UGC付费预览 int32 ugc_pay_preview = 11; // 是否禁止后台播放 int32 no_background = 12; // UGC稿件需要付费 int32 arc_pay = 13; // 是否已付费可自由观看 int32 pay_free_watch = 14; } // message SeasonTheme { // string bg_color = 1; // string selected_bg_color = 2; // string text_color = 3; } // 合作成员信息 message StaffInfo { // 成员mid int64 mid = 1; // 成员角色 string title = 2; // 属性位 // 0:普通 1:赞助商金色标志 int64 attribute = 3; } // 状态数 message Stat { // 稿件avid int64 aid = 1; // 播放数(当屏蔽时为-1) int64 view = 2; // 弹幕数 int32 danmaku = 3; // 评论数 int32 reply = 4; // 收藏数 int32 fav = 5; // 投币数 int32 coin = 6; // 分享数 int32 share = 7; // 当前排名 int32 now_rank = 8; // 历史最高排名 int32 his_rank = 9; // 点赞数 int32 like = 10; // 点踩数(前端不可见故恒为0) int32 dislike = 11; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/card/v1/ad.proto ================================================ syntax = "proto3"; package bilibili.app.card.v1; option java_multiple_files = true; // message AdInfo { // int64 creative_id = 1; // int32 creative_type = 2; // int32 card_type = 3; // CreativeContent creative_content = 4; // string ad_cb = 5; // int64 resource = 6; // int32 source = 7; // string request_id = 8; // bool is_ad = 9; // int64 cm_mark = 10; // int32 index = 11; // bool is_ad_loc = 12; // int32 card_index = 13; // string client_ip = 14; // bytes extra = 15; // int32 creative_style = 16; } // message CreativeContent { // string title = 1; // string description = 2; // int64 video_id = 3; // string username = 4; // string image_url = 5; // string image_md5 = 6; // string log_url = 7; // string log_md5 = 8; // string url = 9; // string click_url = 10; // string show_url = 11; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/card/v1/card.proto ================================================ syntax = "proto3"; package bilibili.app.card.v1; option java_multiple_files = true; import "bilibili/app/card/v1/single.proto"; // 卡片信息 message Card { oneof item { // 小封面条目 SmallCoverV5 small_cover_v5 = 1; // LargeCoverV1 large_cover_v1 = 2; // ThreeItemAllV2 three_item_all_v2 = 3; // ThreeItemV1 three_item_v1 = 4; // HotTopic hot_topic = 5; // DynamicHot three_item_h_v5 = 6; // MiddleCoverV3 middle_cover_v3 = 7; // LargeCoverV4 large_cover_v4 = 8; // 热门列表顶部按钮 PopularTopEntrance popular_top_entrance = 9; // RcmdOneItem rcmd_one_item = 10; // SmallCoverV5Ad small_cover_v5_ad = 11; } } ================================================ FILE: bili-api/grpc/proto/bilibili/app/card/v1/common.proto ================================================ syntax = "proto3"; package bilibili.app.card.v1; option java_multiple_files = true; import "bilibili/app/card/v1/ad.proto"; // message Args { // int32 type = 1; // int64 up_id = 2; // string up_name = 3; // int32 rid = 4; // string rname = 5; // int64 tid = 6; // string tname = 7; // string track_id = 8; // string state = 9; // int32 converge_type = 10; // int64 aid = 11; } // message Avatar { // string cover = 1; // string text = 2; // string uri = 3; // int32 type = 4; // string event = 5; // string event_v2 = 6; // int32 defalut_cover = 7; } // 条目基本信息 message Base { // 卡片类型 string card_type = 1; // 卡片跳转类型? string card_goto = 2; // 跳转类型 // av:视频稿件 mid:用户空间 string goto = 3; // 目标参数 string param = 4; // 封面url string cover = 5; // 标题 string title = 6; // 跳转uri string uri = 7; // ThreePoint three_point = 8; // Args args = 9; // PlayerArgs player_args = 10; // 条目排位序号 int64 idx = 11; // AdInfo ad_info = 12; // Mask mask = 13; //来源标识 // recommend:推荐 operation:管理? string from_type = 14; // repeated ThreePointV2 three_point_v2 = 15; // repeated ThreePointV3 three_point_v3 = 16; // Button desc_button = 17; // 三点v4 ThreePointV4 three_point_v4 = 18; // UpArgs up_args = 19; } // 按钮信息 message Button { // 文案 string text = 1; // 参数 string param = 2; // string uri = 3; // 事件 string event = 4; // int32 selected = 5; // 类型 int32 type = 6; // 事件v2 string event_v2 = 7; // 关系信息 Relation relation = 8; } // message DislikeReason { // int64 id = 1; // string name = 2; } // message LikeButton { // int64 Aid = 1; // int32 count = 2; // bool show_count = 3; // string event = 4; // int32 selected = 5; // string event_v2 = 6; } // message Mask { // Avatar avatar = 1; // Button button = 2; } // message PlayerArgs { // int32 is_live = 1; // int64 aid = 2; // int64 cid = 3; // int32 sub_type = 4; // int64 room_id = 5; // int64 ep_id = 7; // int32 is_preview = 8; // string type = 9; // int64 duration = 10; // int64 season_id = 11; } // 标签框信息 message ReasonStyle { // 文案 string text = 1; // 文字颜色 string text_color = 2; // 背景色 string bg_color = 3; // 边框色 string border_color = 4; // 图标url string icon_url = 5; // 文字颜色-夜间 string text_color_night = 6; // 背景色-夜间 string bg_color_night = 7; // 边框色-夜间 string border_color_night = 8; // 图标url-夜间 string icon_night_url = 9; // 背景风格id // 1:无背景 2:有背景 int32 bg_style = 10; // string uri = 11; // string icon_bg_url = 12; // string event = 13; // string event_v2 = 14; // int32 right_icon_type = 15; // string left_icon_type = 16; } // 关系信息 message Relation { // 关系状态 int32 status = 1; // 是否关注 int32 is_follow = 2; // 是否粉丝 int32 is_followed = 3; } // 分享面板信息 message SharePlane { // 标题 string title = 1; // 副标贴文案 string share_subtitle = 2; // 备注 string desc = 3; // 封面url string cover = 4; // 稿件avid int64 aid = 5; // 稿件bvid string bvid = 6; // 允许分享方式 map share_to = 7; // UP主昵称 string author = 8; // UP主mid int64 author_id = 9; // 短连接 string short_link = 10; // 播放次数文案 string play_number = 11; // int64 first_cid = 12; } // message ThreePoint { // repeated DislikeReason dislike_reasons = 1; // repeated DislikeReason feedbacks = 2; //稍后再看 int32 watch_later = 3; } // message ThreePointV2 { // string title = 1; // string subtitle = 2; // repeated DislikeReason reasons = 3; // string type = 4; // int64 id = 5; } // message ThreePointV3 { // string title = 1; // string selected_title = 2; // string subtitle = 3; // repeated DislikeReason reasons = 4; // string type = 5; // int64 id = 6; // int32 selected = 7; // string icon = 8; // string selected_icon = 9; // string url = 10; // int32 default_id = 11; } // 三点v4 message ThreePointV4 { // 分享面板信息 SharePlane share_plane = 1; // 稍后再看 WatchLater watch_later = 2; } // message Up { // int64 id = 1; // string name = 2; // string desc = 3; // Avatar avatar = 4; // int32 official_icon = 5; // Button desc_button = 6; // string cooperation = 7; } // message UpArgs { // int64 up_id = 1; // string up_name = 2; // string up_face = 3; // int64 selected = 4; } // 稍后再看信息 message WatchLater { // 稿件avid int64 aid = 1; // 稿件bvid string bvid = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/card/v1/double.proto ================================================ syntax = "proto3"; package bilibili.app.card.v1; option java_multiple_files = true; import "bilibili/app/card/v1/common.proto"; // message DoubleCards { oneof Card { // SmallCoverV2 small_cover_v2 = 1; // OnePicV2 one_pic_v2 = 2; // ThreePicV2 three_pic_v2 = 3; } } // message SmallCoverV2 { // Base base = 1; // string cover_gif = 2; // int32 cover_blur = 3; // string cover_left_text_1 = 4; // int32 cover_left_icon_1 = 5; // string cover_left_text_2 = 6; // int32 cover_left_icon_2 = 7; // string cover_right_text = 8; // int32 cover_right_icon = 9; // string cover_right_background_color = 10; // string subtitle = 11; // string badge = 12; // string rcmd_reason = 13; // string desc = 14; // Avatar avatar = 15; // int32 official_icon = 16; // int32 can_play = 17; // ReasonStyle rcmd_reason_style = 18; // ReasonStyle rcmd_reason_style_v2 = 19; // LikeButton like_button = 20; } // message SmallCoverV3 { // Base base = 1; // Avatar avatar = 2; // string cover_left_text = 3; // Button cover_right_button = 4; // string rcmd_reason = 5; // string desc = 6; // int32 official_icon = 7; // int32 can_play = 8; // ReasonStyle rcmd_reason_style = 9; } // message MiddleCoverV2 { // Base base = 1; // int32 ratio = 2; // string desc = 3; // string badge = 4; } // message LargeCoverV2 { // Base base = 1; // Avatar avatar = 2; // string badge = 3; // Button cover_right_button = 4; // string cover_left_text_1 = 5; // int32 cover_left_icon_1 = 6; // string cover_left_text_2 = 7; // int32 cover_left_icon_2 = 8; // string rcmd_reason = 9; // int32 official_icon = 10; // int32 can_play = 11; // ReasonStyle rcmd_reason_style = 12; // int32 show_top = 13; // int32 show_bottom = 14; } // message ThreeItemV2 { // Base base = 1; // int32 title_icon = 2; // string more_uri = 3; // string more_text = 4; // repeated ThreeItemV2Item items = 5; } // message ThreeItemV2Item { // Base base = 1; // int32 cover_left_icon = 2; // string desc_text_1 = 3; // int32 desc_icon_1 = 4; // string desc_text_2 = 5; // int32 desc_icon_2 = 6; // string badge = 7; } // message SmallCoverV4 { // Base base = 1; // string cover_badge = 2; // string desc = 3; // string title_right_text = 4; // int32 title_right_pic = 5; } // message TwoItemV2 { // Base base = 1; // repeated TwoItemV2Item items = 2; } message TwoItemV2Item { // Base base = 1; // string badge = 2; // string cover_left_text_1 = 3; // int32 cover_left_icon_1 = 4; } // message MultiItem { // Base base = 1; // string more_uri = 2; // string more_text = 3; // repeated DoubleCards items = 4; } // message ThreePicV2 { // Base base = 1; // string left_cover = 2; // string right_cover_1 = 3; // string right_cover_2 = 4; // string cover_left_text_1 = 5; // int32 cover_left_icon_1 = 6; // string cover_left_text_2 = 7; // int32 cover_left_icon_2 = 8; // string cover_right_text = 9; // int32 cover_right_icon = 10; // string cover_right_background_color = 11; // string badge = 12; // string rcmd_reason = 13; // string desc = 14; // Avatar avatar = 15; // ReasonStyle rcmd_reason_style = 16; } // message OnePicV2 { // Base base = 1; // int32 cover_left_icon_1 = 2; // string cover_left_text_2 = 3; // string cover_right_text = 4; // int32 cover_right_icon = 5; // string cover_right_background_color = 6; // string badge = 7; // string rcmd_reason = 8; // Avatar avatar = 9; // ReasonStyle rcmd_reason_style = 10; } // message LargeCoverV3 { // Base base = 1; // string cover_gif = 2; // Avatar avatar = 3; // ReasonStyle top_rcmd_reason_style = 4; // ReasonStyle bottom_rcmd_reason_style = 5; // string cover_left_text_1 = 6; // int32 cover_left_icon_1 = 7; // string cover_left_text_2 = 8; // int32 cover_left_icon_2 = 9; // string cover_right_text = 10; // string desc = 11; // int32 official_icon = 12; } // message ThreePicV3 { // Base base = 1; // string left_cover = 2; // string right_cover_1 = 3; // string right_cover_2 = 4; // string cover_left_text_1 = 5; // int32 cover_left_icon_1 = 6; // string cover_left_text_2 = 7; // int32 cover_left_icon_2 = 8; // string cover_right_text = 9; // int32 cover_right_icon = 10; // string cover_right_background_color = 11; // string badge = 12; // ReasonStyle rcmd_reason_style = 13; } // message OnePicV3 { // Base base = 1; // string cover_left_text_1 = 2; // int32 cover_left_icon_1 = 3; // string cover_right_text = 4; // int32 cover_right_icon = 5; // string cover_right_background_color = 6; // string badge = 7; // ReasonStyle rcmd_reason_style = 8; } // message SmallCoverV7 { // Base base = 1; // string desc = 2; } // message SmallCoverV9 { // Base base = 1; // string cover_left_text_1 = 2; // int32 cover_left_icon_1 = 3; // string cover_left_text_2 = 4; // int32 cover_left_icon_2 = 5; // string cover_right_text = 6; // int32 cover_right_icon = 7; // int32 can_play = 8; // ReasonStyle rcmd_reason_style = 9; // Up up = 10; // ReasonStyle left_cover_badge_style = 11; // ReasonStyle left_bottom_rcmd_reason_style = 12; } // message SmallCoverConvergeV2 { // Base base = 1; // string cover_left_text_1 = 2; // int32 cover_left_icon_1 = 3; // string cover_left_text_2 = 4; // int32 cover_left_icon_2 = 5; // string cover_right_text = 6; // string cover_right_top_text = 7; // ReasonStyle rcmd_reason_style = 8; // ReasonStyle rcmd_reason_style_v2 = 9; } // message SmallChannelSpecial { // Base base = 1; // string bg_cover = 2; // string desc_1 = 3; // string desc_2 = 4; // string badge = 5; // ReasonStyle rcmd_reason_style_2 = 6; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/card/v1/single.proto ================================================ syntax = "proto3"; package bilibili.app.card.v1; option java_multiple_files = true; import "bilibili/app/card/v1/common.proto"; // message SmallCoverV5 { // 条目基本信息 Base base = 1; // string cover_gif = 2; // Up up = 3; // 封面右下角标文案 string cover_right_text_1 = 4; // 右侧文案1 string right_desc_1 = 5; // 右侧文案2 string right_desc_2 = 6; // 右侧推荐原因标签框 ReasonStyle rcmd_reason_style = 7; // HotwordEntrance hotword_entrance = 8; // 直播小卡的角标 ReasonStyle corner_mark_style = 9; // 右侧文案1图标id int32 right_icon_1 = 10; // 右侧文案2图标id int32 right_icon_2 = 11; // 左上角角标 ReasonStyle left_corner_mark_style = 12; // string cover_right_text_content_description = 13; // string right_desc1_content_description = 14; } // message SmallCoverV5Ad { // Base base = 1; // string cover_gif = 2; // Up up = 3; // string cover_right_text1 = 4; // string right_desc1 = 5; // string right_desc2 = 6; // ReasonStyle rcmd_reason_style = 7; // HotwordEntrance hotword_entrance = 8; // ReasonStyle corner_mark_style = 9; // int32 right_icon1 = 10; // int32 right_icon2 = 11; // ReasonStyle left_corner_mark_style = 12; // string cover_right_text_content_description = 13; // string right_desc1_content_description = 14; } // message HotwordEntrance { // int64 hotword_id = 1; // string hot_text = 2; // string h5_url = 3; // string icon = 4; } // message LargeCoverV1 { // 条目基本信息 Base base = 1; // string cover_gif = 2; // Avatar avatar = 3; // string cover_left_text_1 = 4; // string cover_left_text_2 = 5; // string cover_left_text_3 = 6; // string cover_badge = 7; // string top_rcmd_reason = 8; // string bottom_rcmd_reason = 9; // string desc = 10; // int32 official_icon = 11; // int32 can_play = 12; // ReasonStyle top_rcmd_reason_style = 13; // ReasonStyle bottom_rcmd_reason_style = 14; // ReasonStyle rcmd_reason_style_v2 = 15; // ReasonStyle left_cover_badge_style = 16; // ReasonStyle right_cover_badge_style = 17; // string cover_badge_2 = 18; // LikeButton like_button = 19; // int32 title_single_line = 20; // string cover_right_text = 21; } // message ThreeItemAllV2 { // 条目基本信息 Base base = 1; // ReasonStyle top_rcmd_reason_style = 2; // repeated TwoItemHV1Item item = 3; } // message TwoItemHV1Item { // string title = 1; // string cover = 2; // string uri = 3; // string param = 4; // Args args = 5; // string goto = 6; // string cover_left_text_1 = 7; // int32 cover_left_icon_1 = 8; // string cover_right_text = 9; } // 推荐 message RcmdOneItem { // 条目基本信息 Base base = 1; // 标签框信息 ReasonStyle topRcmdReasonStyle = 2; // 小封面推荐内容信息 SmallCoverRcmdItem item = 3; } // 小封面推荐内容信息 message SmallCoverRcmdItem { // 标题 string title = 1; // 封面url string cover = 2; // 跳转uri string uri = 3; // 参数 string param = 4; // 跳转类型 // av:视频稿件 string goto = 5; // 封面右下角标文案 string coverRightText1 = 6; // 右侧文案1 string rightDesc1 = 7; // 右侧文案2 string rightDesc2 = 8; // string coverGif = 9; // 右侧文案1图标id int32 rightIcon1 = 10; // 右侧文案2图标id int32 rightIcon2 = 11; // string cover_right_text_content_description = 12; // string right_desc1_content_description = 13; } // message ThreeItemV1 { // 条目基本信息 Base base = 1; // int32 titleIcon = 2; // string moreUri = 3; // string moreText = 4; // repeated ThreeItemV1Item items = 5; } // message ThreeItemV1Item { // 条目基本信息 Base base = 1; // string coverLeftText = 2; // int32 coverLeftIcon = 3; // string desc1 = 4; // string desc2 = 5; // string badge = 6; } // message HotTopicItem { // string cover = 1; // string uri = 2; // string param = 3; // string name = 4; } // message HotTopic { // 条目基本信息 Base base = 1; // string desc = 2; // repeated HotTopicItem items = 3; } // message DynamicHot { // 条目基本信息 Base base = 1; // string top_left_title = 2; // string desc1 = 3; // string desc2 = 4; // string more_uri = 5; // string more_text = 6; // repeated string covers = 7; // string cover_right_text = 8; // ReasonStyle top_rcmd_reason_style = 9; } // message MiddleCoverV3 { // 条目基本信息 Base base = 1; // string desc1 = 2; // string desc2 = 3; // ReasonStyle cover_badge_style = 4; } // message LargeCoverV4 { // 条目基本信息 Base base = 1; // string cover_left_text_1 = 2; // string cover_left_text_2 = 3; // string cover_left_text_3 = 4; // string cover_badge = 5; // int32 can_play = 6; // Up up = 7; // string short_link = 8; // string share_subtitle = 9; // string play_number = 10; // string bvid = 11; // string sub_param = 12; } // 热门列表顶部按钮 message PopularTopEntrance { // 条目基本信息 Base base = 1; // 按钮项 repeated EntranceItem items = 2; } // 热门列表按钮信息 message EntranceItem { // 跳转类型 string goto = 1; // 图标url string icon = 2; // 标题 string title = 3; // 入口模块id string module_id = 4; // 跳转uri string uri = 5; // 入口id int64 entrance_id = 6; // 气泡信息 Bubble bubble = 7; // 入口类型 // 1:代表分品类热门 int32 entrance_type = 8; } // 气泡信息 message Bubble { // 文案 string bubble_content = 1; // 版本 int32 version = 2; // 起始时间 int64 stime = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/click/v1/heartbeat.proto ================================================ syntax = "proto3"; package bilibili.app.click.v1; option java_multiple_files = true; service Click { } // 账户信息 message AccountInfo { // uint64 mid = 1; } // message AppInfo { // string top_page_class = 1; // 客户端首次启动时的毫秒时间戳 int64 ftime = 2; // string did = 3; } // 心跳补充信息 message Extra { // string session = 1; // string refer = 2; } message HeartBeatReply {} // message HeartBeatReq { // string session_v2 = 1; // Stage stage = 2; // 流加载失败timeout uint64 stream_timeout = 3; // uint64 batch_frequency = 4; // float frequency = 5; // VideoMeta video_meta = 6; // AppInfo app_info = 7; // AccountInfo account_info = 8; // PreProcessResult pre_process_result = 9; // repeated PlayerStatus player_status = 10; // VideoInfo video_info = 11; } // message PlayerStatus { // float playback_rate = 1; // uint64 progress = 2; // PlayState play_state = 3; // bool is_buffering = 4; } // enum PlayState { // STATE_UNKNOWN = 0; // PREPARING = 1; // PREPARED = 2; // PLAYING = 3; // PAUSED = 4; // STOPPED = 5; // FAILED = 6; } // message PreProcessResult { // int64 vt = 1; } // enum Stage { // STAGE_UNKNOWN = 0; // START = 1; // END = 2; // SAMPLE = 3; } // message VideoInfo { // uint64 cid_duration = 1; } // message VideoMeta { // uint64 aid = 1; // uint64 cid = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/download.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.download; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message DownloadSettingsConfig { // bilibili.app.distribution.v1.BoolValue enable_download_auto_start = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/dynamic.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.dynamic; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message DynamicAutoPlay { // bilibili.app.distribution.v1.Int64Value value = 1; } // message DynamicDeviceConfig { // DynamicAutoPlay auto_play = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/experimental.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.experimental; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message DynamicSelect { // bilibili.app.distribution.v1.BoolValue fold = 1; } // message Exp { // bilibili.app.distribution.v1.Int64Value id = 1; // bilibili.app.distribution.v1.Int32Value bucket = 2; } // message ExperimentalConfig { // bilibili.app.distribution.v1.StringValue flag = 1; // repeated Exp exps = 2; } // message MultipleTusConfig { // TopLeft top_left = 1; // DynamicSelect dynamic_select = 2; } // APP首页头像跳转信息 message TopLeft { // bilibili.app.distribution.v1.StringValue url = 1; // bilibili.app.distribution.v1.StringValue story_foreground_image = 2; // bilibili.app.distribution.v1.StringValue story_background_image = 3; // bilibili.app.distribution.v1.StringValue listen_foreground_image = 4; // bilibili.app.distribution.v1.StringValue listen_background_image = 5; // bilibili.app.distribution.v1.StringValue ios_story_foreground_image = 6; // bilibili.app.distribution.v1.StringValue ios_story_background_image = 7; // bilibili.app.distribution.v1.StringValue ios_listen_foreground_image = 8; // bilibili.app.distribution.v1.StringValue ios_listen_background_image = 9; // bilibili.app.distribution.v1.StringValue goto = 10; // bilibili.app.distribution.v1.StringValue url_v2 = 11; // bilibili.app.distribution.v1.Int64Value goto_v2 = 12; // bilibili.app.distribution.v1.StringValue badge = 13; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/internaldevice.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.internaldevice; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message InternalDeviceConfig { // 首次启动时间 bilibili.app.distribution.v1.Int64Value fts = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/night.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.night; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message NightSettingsConfig { // bilibili.app.distribution.v1.BoolValue is_night_follow_system = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/other.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.other; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message OtherSettingsConfig { // bilibili.app.distribution.v1.Int64Value watermark_type = 1; // bilibili.app.distribution.v1.Int64Value web_image_quality_type = 2; // bilibili.app.distribution.v1.BoolValue enable_read_pasteboard = 3; // bilibili.app.distribution.v1.BoolValue paste_auto_jump = 4; // bilibili.app.distribution.v1.BoolValue mini_screen_play_when_back = 5; // bilibili.app.distribution.v1.BoolValue enable_resume_playing = 6; // bilibili.app.distribution.v1.BoolValue enable_wifi_auto_update = 7; // bilibili.app.distribution.v1.BoolValue enable_guide_screenshot_share = 8; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/pegasus.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.pegasus; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message FeedModeValue { // bilibili.app.distribution.v1.Int64Value value = 1; } // message PegasusAutoPlay { // bilibili.app.distribution.v1.Int64Value single = 1; // bilibili.app.distribution.v1.Int64Value double = 2; // bilibili.app.distribution.v1.BoolValue single_affected_by_server_side = 3; // bilibili.app.distribution.v1.BoolValue double_affected_by_server_side = 4; } // message PegasusColumnValue { // bilibili.app.distribution.v1.Int64Value value = 1; // bilibili.app.distribution.v1.BoolValue affected_by_server_side = 2; } // message PegasusDeviceConfig { // PegasusColumnValue column = 1; // FeedModeValue mode = 2; // PegasusAutoPlay auto_play = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/play.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.play; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // 云端保存的播放器配置 message CloudPlayConfig { // 启用杜比全景声 bilibili.app.distribution.v1.BoolValue enable_panorama = 1; // 启用杜比音效 bilibili.app.distribution.v1.BoolValue enable_dolby = 2; // 启用震动 bilibili.app.distribution.v1.BoolValue enable_shake = 3; // 启用后台播放 bilibili.app.distribution.v1.BoolValue enable_background = 4; // 启用HIRES bilibili.app.distribution.v1.BoolValue enable_loss_less = 5; } // 播放器策略配置 message PlayConfig { // bilibili.app.distribution.v1.BoolValue should_auto_play = 1; // bilibili.app.distribution.v1.BoolValue should_auto_fullscreen = 2; // bilibili.app.distribution.v1.BoolValue enable_playurl_https = 3; // bilibili.app.distribution.v1.BoolValue enable_danmaku_interaction = 4; // bilibili.app.distribution.v1.Int64Value small_screen_status = 5; // bilibili.app.distribution.v1.Int64Value player_codec_mode_key = 6; // bilibili.app.distribution.v1.BoolValue enable_gravity_rotate_screen = 7; // bilibili.app.distribution.v1.BoolValue enable_danmaku_monospaced = 8; // bilibili.app.distribution.v1.BoolValue enable_edit_subtitle = 9; // bilibili.app.distribution.v1.BoolValue enable_subtitle = 10; // bilibili.app.distribution.v1.Int64Value color_filter = 11; // bilibili.app.distribution.v1.BoolValue should_auto_story = 12; // bilibili.app.distribution.v1.BoolValue landscape_auto_story = 13; // bilibili.app.distribution.v1.BoolValue volume_balance = 14; } // 灰度测试特殊功能? message SpecificPlayConfig { // bilibili.app.distribution.v1.BoolValue enable_segmented_section = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/privacy.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.privacy; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message MidPrivacySettingsConfig { // bilibili.app.distribution.v1.BoolValue recommend_to_known = 1; } // message PrivacySettingsConfig { // bilibili.app.distribution.v1.BoolValue ad_recommand_store = 1; // 传感器权限 bilibili.app.distribution.v1.BoolValue sensor_access = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/setting/search.proto ================================================ syntax = "proto3"; package bilibili.app.distribution.setting.search; option java_multiple_files = true; import "bilibili/app/distribution/v1/distribution.proto"; // message SearchAutoPlay { // bilibili.app.distribution.v1.Int64Value value = 1; // bilibili.app.distribution.v1.BoolValue affected_by_server_side = 2; } // message SearchDeviceConfig { // SearchAutoPlay auto_play = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/distribution/v1/distribution.proto ================================================ syntax = "proto3"; import "google/protobuf/any.proto"; package bilibili.app.distribution.v1; option java_multiple_files = true; // APP配置 service Distribution { // 获取云端储存的用户偏好 rpc GetUserPreference (GetUserPreferenceReq) returns (GetUserPreferenceReply); // 设定用户偏好 rpc SetUserPreference (SetUserPreferenceReq) returns (SetUserPreferenceReply); // 获取云控配置 rpc UserPreference (UserPreferenceReq) returns (UserPreferenceReply); } // message GetUserPreferenceReq { // repeated string type_url = 1; // map extra_context = 2; } // message GetUserPreferenceReply { // 对应 GetUserPreferenceReq 的请求的类型 repeated google.protobuf.Any value = 1; } // message SetUserPreferenceReq { // repeated google.protobuf.Any preference = 1; // map extra_context = 2; } // message SetUserPreferenceReply {} // message UserPreferenceReq {} // 云控配置下发 message UserPreferenceReply { // 具体解码需要根据实际请求 type_url 来判断 repeated google.protobuf.Any preference = 1; } // message BoolValue { // bool value = 1; // int64 last_modified = 2; // bool default_value = 3; // string exp = 4; } // message BytesValue { // bytes value = 1; // int64 last_modified = 2; // bytes default_value = 3; // string exp = 4; } // message DoubleValue { // double value = 1; // int64 last_modified = 2; // double default_value = 3; // string exp = 4; } // message FloatValue { // float value = 1; // int64 last_modified = 2; // float default_value = 3; // string exp = 4; } // message Int32Value { // int32 value = 1; // int64 last_modified = 2; // int32 default_value = 3; // string exp = 4; } // message Int64Value { // int64 value = 1; // int64 last_modified = 2; // int64 default_value = 3; // string exp = 4; } // message StringValue { // string value = 1; // int64 last_modified = 2; // string default_value = 3; // string exp = 4; } // message UInt32Value { // uint32 value = 1; // int64 last_modified = 2; // uint32 default_value = 3; // string exp = 4; } // message UInt64Value { // uint64 value = 1; // int64 last_modified = 2; // uint64 default_value = 3; // string exp = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/dynamic/common/dynamic.proto ================================================ syntax = "proto3"; package bilibili.app.dynamic.common; option java_multiple_files = true; // message ItemWHRatio { // int32 ratio = 1; // int32 width = 2; // int32 height = 3; } // enum WHRatio { W_H_RATIO_1_1 = 0; W_H_RATIO_16_9 = 1; W_H_RATIO_3_4 = 2; W_H_RATIO_CUSTOM = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/dynamic/v1/dynamic.proto ================================================ syntax = "proto3"; package bilibili.app.dynamic.v1; option java_multiple_files = true; import "bilibili/app/archive/middleware/v1/preload.proto"; // v1动态 service Dynamic { // 动态视频页 rpc DynVideo (DynVideoReq) returns (DynVideoReqReply); // 批量动态id获取动态详情 rpc DynDetails (DynDetailsReq) returns (DynDetailsReply); // 小视频连播页 rpc SVideo (SVideoReq) returns (SVideoReply); // 动态tab页 rpc DynTab (DynTabReq) returns (DynTabReply); // 同城接口开关 rpc DynOurCitySwitch (DynOurCitySwitchReq) returns (NoReply); // 动态同城页 rpc DynOurCity(DynOurCityReq) returns (DynOurCityReply); // 最近访问-个人视频feed流 rpc DynVideoPersonal(DynVideoPersonalReq) returns (DynVideoPersonalReply); // 最近访问-标记已读 rpc DynUpdOffset(DynUpdOffsetReq) returns (NoReply); // 动态红点接口 rpc DynRed(DynRedReq) returns(DynRedReply); // 查看更多-列表 rpc DynMixUpListViewMore(NoReq) returns (DynMixUpListViewMoreReply); // 查看更多-搜索 rpc DynMixUpListSearch(DynMixUpListSearchReq) returns (DynMixUpListSearchReply); // 同城点击上报 rpc OurCityClickReport(OurCityClickReportReq) returns (OurCityClickReportReply); // 位置定位 rpc GeoCoder(GeoCoderReq) returns (GeoCoderReply); } // 地址部件 message AddressComponent { // 国家 string nation = 1; // 省 string province = 2; // 市 string city = 3; // 区,可能为空字串 string district = 4; // 街道,可能为空字串 string street = 5; // 门牌,可能为空字串 string street_number = 6; } // 行政区划信息 message AdInfo { // 国家代码(ISO3166标准3位数字码) string nation_code = 1; // 行政区划代码,规则详见:行政区划代码说明 string adcode = 2; // 城市代码,由国家码+行政区划代码(提出城市级别)组合而来,总共为9位 string city_code = 3; // 行政区划名称 string name = 4; // 行政区划中心点坐标 Gps gps = 5; } // enum BgType { bg_type_default = 0; // bg_type_face = 1; // } // 付费课程批次卡 message CardCurrBatch { // 标题 string title = 1; // 封面图 string cover = 2; // 跳转地址 string uri = 3; // 展示项 1(本集标题) string text_1 = 4; // 展示项 2(更新了多少个视频) string text_2 = 5; // 角标 VideoBadge badge = 6; } // 付费课程系列卡 message CardCurrSeason { // 标题 string title = 1; // 封面图 string cover = 2; // 跳转地址 string uri = 3; // 展示项 1(更新信息) string text_1 = 4; // 描述信息 string desc = 5; // 角标 VideoBadge badge = 6; } // PGC视频卡片数据 message CardPGC { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // cid int64 cid = 7; // season_id int64 season_id = 8; // epid int64 epid = 9; // aid int64 aid = 10; // 视频源类型 MediaType media_type = 11; // 番剧类型 VideoSubType sub_type = 12; // 番剧是否为预览视频 0:否,1:是 int32 is_preview = 13; // 尺寸信息 Dimension dimension = 14; // 角标 repeated VideoBadge badge = 15; // 是否能够自动播放 int32 can_play = 16; // PGC单季信息 PGCSeason season = 17; } // UGC视频卡片数据 message CardUGC { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // avid int64 avid = 7; // cid int64 cid = 8; // 视频源类型 MediaType media_type = 9; // 尺寸信息 Dimension dimension = 10; // 角标 repeated VideoBadge badge = 11; // 是否能够自动播放 int32 can_play = 12; } // enum CornerType { corner_type_none = 0; // corner_type_text = 1; // corner_type_animation = 2; // } // 粉丝样式 message DecoCardFan { // 是否是粉丝 int32 is_fan = 1; // 数量 int32 number = 2; // 颜色 string color = 3; } // 装扮卡片 message DecorateCard { // 装扮卡片id int64 id = 1; // 装扮卡片链接 string card_url = 2; // 装扮卡片点击跳转链接 string jump_url = 3; // 粉丝样式 DecoCardFan fan = 4; } // 文本描述 message Description { // 文本内容 string text = 1; // 文本类型 string type = 2; // 点击跳转链接 string uri = 3; // emoji类型 string emoji_type = 4; // 商品类型 string goods_type = 5; } // 尺寸信息 message Dimension { // int64 height = 1; // int64 width = 2; // int64 rotate = 3; } // 动态卡片项 message DynamicItem { // 卡片类型 // forward:转发 av:稿件视频 fold:折叠 pgc:pgc内容 courses:付费视频 upList:最近访问列表 followList:我的追番列表 string card_type = 1; // 转发类型下,items的类型 string item_type = 2; // 模块内容 repeated Module modules = 3; // 动态ID str string dyn_id_str = 4; // 转发动态ID str string orig_dyn_id_str = 5; // r_type int32 r_type = 6; // 该卡片下面是否含有折叠卡 int32 has_fold = 7; } // 批量动态id获取动态详情返回值 message DynDetailsReply { // 动态列表 repeated DynamicItem list = 1; } // 批量动态id获取动态详情请求参数 message DynDetailsReq { // 青少年模式 int32 teenagers_mode = 1; // 动态id string dynamic_ids = 2; // 清晰度 int32 qn = 3; // 流版本 int32 fnver = 4; // 流功能 int32 fnval = 5; // 是否强制使用域名 int32 force_host = 6; // 是否4k int32 fourk = 7; } // 查看更多-搜索-响应 message DynMixUpListSearchReply { // repeated MixUpListItem items = 1; } // 查看更多-搜索-请求 message DynMixUpListSearchReq { // string name = 1; } // 查看更多-列表-响应 message DynMixUpListViewMoreReply { // 关注up主列表信息 repeated MixUpListItem items = 1; // 默认搜索文案 string search_default_text = 2; } // 动态同城物料 message DynOurCityItem { // 卡片类型 // av:稿件 draw:图文 string card_type = 1; // 动态ID int64 dyn_id = 2; // 跳转地址 string uri = 3; // 模块列表 repeated DynOurCityModule modules = 4; // 资源ID int64 rid = 5; // 透传服务端魔镜参数 string debug_info = 6; } // 动态同城物料模块 message DynOurCityModule { // 类型 // cover:封面 desc:描述 author:发布人 extend:扩展部分 string module_type = 1; // oneof module_item { // 封面 DynOurCityModuleCover module_cover = 2; // 描述 DynOurCityModuleDesc module_desc = 3; // 发布人 DynOurCityModuleAuthor module_author = 4; // 扩展部分 DynOurCityModuleExtend module_extend = 5; } } // 动态同城物料-发布人模块 message DynOurCityModuleAuthor { // 用户Mid int64 mid = 1; // 用户昵称 string name = 2; // 用户头像 string face = 3; // 跳转地址 string uri = 4; } // 动态同城物料-封面模块 message DynOurCityModuleCover { // 封面图 单图样式取第一个元素 repeated string covers = 1; // 封面样式 // 1:横图 2:竖图 3:方图 int32 style = 2; // 视频封面展示项图标 1 int32 cover_left_icon_1 = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项图标 2 int32 cover_left_icon_2 = 5; // 视频封面展示项 2 string cover_left_text_2 = 6; // 封面视频展示项 3 string cover_left_text_3 = 7; // 角标 repeated VideoBadge badge = 8; } // 动态同城物料-描述模块 message DynOurCityModuleDesc { // 描述信息 string desc = 1; } // 动态同城物料-扩展部分模块 message DynOurCityModuleExtend { // 类型 string type = 1; oneof extend { // LBS模块 DynOurCityModuleExtendLBS extend_lbs = 2; } } // 动态同城物料extent-LBS模块 message DynOurCityModuleExtendLBS { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // poiType int32 poi_type = 4; } // 动态同城-响应 message DynOurCityReply { // 翻页游标 string offset = 1; // 是否还有更多数据 // 1:有 int32 has_more = 2; // 样式类型 // 1:双列 2:瀑布流 int32 style = 3; // 顶导信息 string top_label = 4; // 列表详情 repeated DynOurCityItem list = 5; // 顶导按钮信息 string top_button_label = 6; // 城市ID int32 city_id = 7; // 城市名 string city_name = 8; } // 动态同城页-请求 message DynOurCityReq { // 城市ID int64 city_id = 1; // 纬度 double lat = 2; // 经度 double lng = 3; // 透传上一次接口请求返回的offset string offset = 4; // 每页元素个数 int32 page_size = 5; // 青少年模式 // 1:开启青少年模式 int32 teenagers_mode = 6; // 清晰度(旧版) int32 qn = 7; // 流版本(旧版) int32 fnver = 8; // 流类型(旧版) int32 fnval = 9; // 是否强制使用域名(旧版) int32 force_host = 10; // 是否4k(旧版) int32 fourk = 11; // 是否开启lbs // 0:关闭 1:开启 int32 lbs_state = 12; // 是否刷新城市 uint32 refresh_city = 13; // 魔镜设置 ExpConf exp_conf = 14; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 15; // 城市码 int64 city_code = 16; // 构建时间 int64 build_time = 17; } // 动态同城开关-请求 message DynOurCitySwitchReq { // 开关参数 // 0:关闭 1:开启 int32 switch = 1; } // 红点接口物料 message DynRedItem { // 数字红点有效 更新数 uint64 count = 1; } // 红点接口-响应 message DynRedReply { // 类型 // count:数字红点 point:普通红点 no_point:没有红点 string red_type = 1; // 红点具体信息 DynRedItem dyn_red_item = 2; // 默认tab 值对应tab接口下发的anchor string default_tab = 3; // DynRedStyle red_style = 4; } // 动态红点接口-请求 message DynRedReq { // 动态红点接口各tab offset信息 repeated TabOffset tab_offset = 1; } // message DynRedStyle { // int32 bg_type = 1; // int32 corner_type = 2; // int32 display_time = 3; // string corner_mark = 4; // DynRedStyleUp up = 5; // int32 type = 6; } // message DynRedStyleUp { // int64 uid = 1; // string face = 2; } // 动态tab详情 message DynTab { // tab标题 优先展示用,未开启状态第一次请求返回同城,后续请求返回对应城市名 string title = 1; // 跳转链接 string uri = 2; // 气泡内容 string bubble = 3; // 是否推红点 int32 red_point = 4; // 城市ID int64 city_id = 5; // 是否弹窗 int32 is_popup = 6; // 弹窗内容 Popup popup = 7; // 是否默认tab bool defaultTab = 8; // 副标题 对应城市名 string sub_title = 9; // 锚点字段 string anchor = 10; // 内测文案 string internal_test = 11; } // 动态tab页-响应 message DynTabReply { // 动态tab详情列表 repeated DynTab dyn_tab = 1; } // 动态tab页-请求 message DynTabReq { // 青少年模式 // 1:开启青少年模式 int32 teenagers_mode = 1; } // 最近访问-标记已读-请求 message DynUpdOffsetReq { // 被访问者的UID int64 host_uid = 1; // 用户已读进度 string read_offset = 2; } // 最近访问-个人feed流列表-响应 message DynVideoPersonalReply { // 动态列表 repeated DynamicItem list = 1; // 偏移量 string offset = 2; // 是否还有更多数据 int32 has_more = 3; // 已读进度 string read_offset = 4; } // 最近访问-个人feed流列表-请求 message DynVideoPersonalReq { // 青少年模式 // 1:开启青少年模式 int32 teenagers_mode = 1; // 被访问者的mid int64 host_uid = 2; // 偏移量 第一页可传空 string offset = 3; // 标明下拉几次 int32 page = 4; // 是否是预加载 int32 is_preload = 5; // 清晰度 int32 qn = 6; // 流版本 int32 fnver = 7; // 流类型 int32 fnval = 8; // 是否强制使用域名 int32 force_host = 9; // 是否4k int32 fourk = 10; } // 动态视频页-请求 message DynVideoReq { // 青少年模式 int32 teenagers_mode = 1; // 透传 update_baseline string update_baseline = 2; // 透传 history_offset string offset = 3; // 向下翻页数 int32 page = 4; // 刷新方式 // 1:向上刷新 2:向下翻页 int32 refresh_type = 5; // 清晰度 int32 qn = 6; // 流版本 int32 fnver = 7; // 流类型 int32 fnval = 8; // 是否强制使用域名 int32 force_host = 9; // 是否4K int32 fourk = 10; } // 动态视频页-响应 message DynVideoReqReply { // 动态列表 repeated DynamicItem list = 1; // 更新的动态数 int32 update_num = 2; // 历史偏移 string history_offset = 3; // 更新基础信息 string update_baseline = 4; // 是否还有更多数据 int32 has_more = 5; } // 魔镜实验配置项 message Exp { // 实验名 string exp_name = 1; // 实验组 string exp_group = 2; } // 魔镜设置 message ExpConf { // 是否是魔镜请求 int32 exp_enable = 1; // 实验配置 repeated Exp exps = 2; } // 拓展 message Extend { // 类型 // topic:话题小卡 lbs:lbs hot:热门视频 game:游戏 string type = 1; // 卡片详情 oneof extend { // 话题小卡 ExtInfoTopic ext_info_topic = 2; // lbs ExtInfoLBS ext_info_lbs = 3; // 热门视频 ExtInfoHot ext_info_hot = 4; // 游戏 ExtInfoGame ext_info_game = 5; } } // 拓展信息-游戏小卡 message ExtInfoGame { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 拓展信息-热门视频 message ExtInfoHot { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 拓展信息-lbs message ExtInfoLBS { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // poiType int32 poi_type = 4; } // 拓展信息-话题小卡 message ExtInfoTopic { // 标题-话题名 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 折叠分类 enum FoldType { FoldTypeZero = 0; // 占位 FoldTypePublish = 1; // 用户发布折叠 FoldTypeFrequent = 2; // 转发超频折叠 FoldTypeUnite = 3; // 联合投稿折叠 FoldTypeLimit = 4; // 动态受限折叠 } // 我的追番列表Item message FollowListItem { // season_id int32 season_id = 1; // 标题 string title = 2; // 封面图 string cover = 3; // 跳转链接 string url = 4; // 最新ep NewEP new_ep = 5; } // 位置定位-响应 message GeoCoderReply { // 以行政区划+道路+门牌号等信息组成的标准格式化地址 string address = 1; // 地址部件,address不满足需求时可自行拼接 AddressComponent address_component = 2; // 行政区划信息 AdInfo ad_info = 3; } // 位置定位-请求 message GeoCoderReq { // 纬度 double lat = 1; // 经度 double lng = 2; // 页面来源 string from = 3; } // 行政区划中心点坐标 message Gps { // 纬度 double lat = 1; // 经度 double lng = 2; } // 点赞动画 message LikeAnimation { // 开始动画 string begin = 1; // 过程动画 string proc = 2; // 结束动画 string end = 3; // id int64 like_icon_id = 4; } // 点赞拓展信息 message LikeInfo { // 点赞动画 LikeAnimation animation = 1; // 是否点赞 int32 is_like = 2; } // 点赞用户 message LikeUser { // 用户mid int64 uid = 1; // 用户昵称 string uname = 2; // 点击跳转链接 string uri = 3; } // 直播信息 message LiveInfo { // 是否在直播 // 0:未直播 1:正在直播 int32 is_living = 1; // 跳转链接 string uri = 2; } // 播放器类型 enum MediaType { MediaTypeNone = 0; // 本地 MediaTypeUGC = 1; // UGC MediaTypePGC = 2; // PGC MediaTypeLive = 3; // 直播 MediaTypeVCS = 4; // 小视频 } // 查看更多-列表单条数据 message MixUpListItem { // 用户mid int64 uid = 1; // 特别关注 // 0:否 1:是 int32 special_attention = 2; // 小红点状态 // 0:没有 1:有 int32 reddot_state = 3; // 直播信息 MixUpListLiveItem live_info = 4; // 昵称 string name = 5; // 头像 string face = 6; // 认证信息 OfficialVerify official = 7; // 大会员信息 VipInfo vip = 8; // 关注状态 Relation relation = 9; // int32 premiere_state = 10; // string uri = 11; } // 直播信息 message MixUpListLiveItem { // 直播状态 // 0:未直播 1:直播中 bool status = 1; // 房间号 int64 room_id = 2; // 跳转地址 string uri = 3; } // 模块 message Module { // 类型 // fold:折叠 author:发布人 dynamic:动态卡片内容 state:计数信息 forward:转发 extend:小卡信息 dispute:争议小黄条 desc:描述信息 // likeUser:点赞用户 upList:最近访问列表 followList:我的追番 string module_type = 1; oneof module_item{ // 折叠 ModuleFold module_fold = 2; // 发布人 ModuleAuthor module_author = 3; // 动态卡片内容 ModuleDynamic module_dynamic = 4; // 计数信息 ModuleState module_state = 5; // 转发 ModuleForward module_forward = 6; // 小卡信息 ModuleExtend module_extend = 7; // 争议小黄条 ModuleDispute module_dispute = 8; // 描述信息 ModuleDesc module_desc = 9; // 点赞用户 ModuleLikeUser module_likeUser = 10; // 最近访问列表 ModuleDynUpList module_upList = 11; // 我的追番 ModuleFollowList module_followList = 12; } } // 作者信息模块 message ModuleAuthor { // 用户mid int64 id = 1; // 时间标签 string ptime_label_text = 2; // 用户详情 UserInfo author = 3; // 装扮卡片 DecorateCard decorate_card = 4; } // 文本内容模块 message ModuleDesc { // 文本描述 repeated Description desc = 1; } // 争议小黄条模块 message ModuleDispute { // 标题 string title = 1; // 描述内容 string desc = 2; // 跳转链接 string uri = 3; } // 动态详情模块 message ModuleDynamic { // 卡片类型 // ugc:ugc卡 pgc:pgc卡 currSeason:付费课程系列 currBatch:付费课程批次 string card_type = 1; // 正文卡片 oneof card { // ugc卡 CardUGC card_ugc = 2; // pgc卡 CardPGC card_pgc = 3; // 付费课程系列 CardCurrSeason card_curr_season = 4; // 付费课程批次 CardCurrBatch card_curr_batch = 5; } } // 最近访问up主列表 message ModuleDynUpList { // 标题展示文案 string module_title = 1; // “全部”按钮文案 string show_all = 2; // up主列表 repeated UpListItem list = 3; } // 拓展信息 message ModuleExtend { // 拓展 repeated Extend extend = 1; } // 折叠模块 message ModuleFold { // 折叠分类(该字段废弃) int32 fold_type = 1; // 折叠文案 string text = 2; // 被折叠的动态 string fold_ids = 3; // 被折叠的用户信息 repeated UserInfo fold_users = 4; // 折叠分类 FoldType fold_type_v2 = 5; } // 我的追番列表 message ModuleFollowList { // 查看全部的跳转链接 string view_all_link = 1; // repeated FollowListItem list = 2; } // 转发模块 message ModuleForward { // 卡片类型 string card_type = 1; // 嵌套模型 repeated Module modules = 2; } // 点赞用户模块 message ModuleLikeUser { // 点赞用户 repeated LikeUser like_users = 1; // 文案 string display_text = 2; } // 计数信息模块 message ModuleState { // 转发数 int32 repost = 1; // 点赞数 int32 like = 2; // 评论数 int32 reply = 3; // 点赞拓展信息 LikeInfo like_info = 4; // 禁评 bool no_comment = 5; // 禁转 bool no_forward = 6; } // 认证名牌 message Nameplate { // nid int64 nid = 1; // 名称 string name = 2; // 图片地址 string image = 3; // 小图地址 string image_small = 4; // 等级 string level = 5; // 获取条件 string condition = 6; } // 最新ep message NewEP { // 最新话epid int32 id = 1; // 更新至XX话 string index_show = 2; // 更新剧集的封面 string cover = 3; } // 空响应 message NoReply { } // 空请求 message NoReq { } // 认证信息 message OfficialVerify { // 认证类型 // 127:未认证 0:个人 1:机构 int32 type = 1; // 认证描述 string desc = 2; // int32 is_atten = 3; } // 动态同城点击上报-响应 message OurCityClickReportReply { } // 动态同城点击上报-请求 message OurCityClickReportReq { // 动态ID string dynamic_id = 1; // 城市ID int64 city_id = 2; // 纬度 double lat = 3; // 经度 double lng = 4; } // PGC单季信息 message PGCSeason { // 是否完结 int32 is_finish = 1; // 标题 string title = 2; // 类型 int32 type = 3; } // 秒开参数 message PlayerPreloadParams { // 清晰度 int32 qn = 1; // 流版本 int32 fnver = 2; // 流类型 int32 fnval = 3; // 是否强制使用域名 int32 force_host = 4; // 是否4k int32 fourk = 5; } // 动态tab弹窗详情 message Popup { // 标题 string title = 1; // 文案 string desc = 2; // 文案附加跳转地址 string uri = 3; } // 关注关系 message Relation { // 关注状态 RelationStatus status = 1; // 关注 int32 is_follow = 2; // 被关注 int32 is_followed = 3; // 文案 string title = 4; } // 关注状态 enum RelationStatus { relation_status_none = 0; // relation_status_nofollow = 1; // 未关注 relation_status_follow = 2; // 关注 relation_status_followed = 3; // 被关注 relation_status_mutual_concern = 4; // 互相关注 relation_status_special = 5; // 特别关注 } // 分享需要 message ShareInfo { // 稿件avid int64 aid = 1; // 稿件bvid string bvid = 2; // 标题 string title = 3; // 副标题 string subtitle = 4; // 稿件封面 string cover = 5; // up mid int64 mid = 6; // up昵称 string name = 7; } // enum StyleType { STYLE_TYPE_NONE = 0; // STYLE_TYPE_LIVE = 1; // STYLE_TYPE_DYN_UP = 2; // } // 小视频卡片项 message SVideoItem { // 卡片类型 // av:稿件视频 string card_type = 1; // 模块内容 repeated SVideoModule modules = 2; // 动态ID str string dyn_id_str = 3; // 卡片游标 int64 index = 4; } // 小视频模块 message SVideoModule { // 类型 // author:发布人 player:播放器内容 desc:描述信息 stat:计数信息 string module_type = 1; oneof module_item { // 发布人 SVideoModuleAuthor module_author = 2; // 播放器内容 SVideoModulePlayer module_player = 3; // 描述信息 SVideoModuleDesc module_desc = 4; // 计数信息 SVideoModuleStat module_stat = 5; } } // 作者信息模块 message SVideoModuleAuthor { // 用户mid int64 mid = 1; // 用户昵称 string name = 2; // 用户头像 string face = 3; // 发布描述 string pub_desc = 4; // 是否关注up // 1:已关注 int32 is_attention = 5; // 跳转地址 string uri = 6; } // 文本内容模块 message SVideoModuleDesc { // 文本内容 string text = 1; // 跳转地址 string uri = 2; } // 播放器模块 message SVideoModulePlayer { // 标题 string title = 1; // 封面图 string cover = 2; // 跳转地址,秒开地址如果有会拼接player_preload可参考天马 string uri = 3; // aid int64 aid = 4; // cid int64 cid = 5; // 视频时长 int64 duration = 6; // 尺寸信息 Dimension dimension = 7; } // 计数信息模块 message SVideoModuleStat { // 计数内容 repeated SVideoStatInfo stat_info = 1; // 分享需要 ShareInfo share_info = 2; } // 小视频连播页-响应 message SVideoReply { // 列表 repeated SVideoItem list = 1; // 翻页游标 string offset = 2; // 是否还有更多数据 // 1:有 int32 has_more = 3; // 顶部 SVideoTop top = 4; } // 小视频连播页-请求 message SVideoReq { // 当前素材的id int64 oid = 1; // 当前素材类型 // 1:动态(如果有oid则必传) 2:热门分类 3:热点聚合 SVideoType type = 2; // 翻页offset string offset = 3; // 清晰度(旧版) int32 qn = 4; // 流版本(旧版) int32 fnver = 5; // 流类型(旧版) int32 fnval = 6; // 是否强制使用域名(旧版) int32 force_host = 7; // 是否4k(旧版) int32 fourk = 8; // 当前页面spm string spmid = 9; // 上级页面spm string from_spmid = 10; // 秒开参数 PlayerPreloadParams player_preload = 11; // 热门进入联播页的锚点aid int64 focus_aid = 12; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 13; } // 计数内容 message SVideoStatInfo { // 计数icon // 1:分享符号 2:评论符号 3:点赞符号 int32 icon = 1; // 计数值 int64 num = 2; // 选中状态 // 1:选中 int32 selected = 3; // 跳转链接(如评论) string uri = 4; } // 顶部 message SVideoTop { // 联播页标题 string Title = 1; // 联播页导语 string Desc = 2; } // 入口联播页类型 enum SVideoType { TypeNone = 0; // 无类型 TypeDynamic = 1; // 动态 TypePopularIndex = 2; // 热门分类 TypePopularHotword = 3; // 热点聚合 } // 动态红点接口各tab offset信息 message TabOffset { // 1:综合页 2:视频页 int32 tab = 1; // 上一次对应列表页offset string offset = 2; } // up主列表 message UpListItem { // 是否有更新 // 0:没有 1:有 int32 has_update = 1; // up主头像 string face = 2; // up主昵称 string name = 3; // up主uid int64 uid = 4; } // 用户信息 message UserInfo { // 用户mid int64 mid = 1; // 用户昵称 string name = 2; // 用户头像 string face = 3; // 认证信息 OfficialVerify official = 4; // 大会员信息 VipInfo vip = 5; // 直播信息 LiveInfo live = 6; // 空间页跳转链接 string uri = 7; // 挂件信息 UserPendant pendant = 8; // 认证名牌 Nameplate nameplate = 9; } // 头像挂件信息 message UserPendant { // pid int64 pid = 1; // 名称 string name = 2; // 图片链接 string image = 3; // 有效期 int64 expire = 4; } // 角标信息 message VideoBadge { // 文案 string text = 1; // 文案颜色-日间 string text_color = 2; // 文案颜色-夜间 string text_color_night = 3; // 背景颜色-日间 string bg_color = 4; // 背景颜色-夜间 string bg_color_night = 5; // 边框颜色-日间 string border_color = 6; // 边框颜色-夜间 string border_color_night = 7; // 样式 int32 bg_style = 8; } // 番剧类型 enum VideoSubType { VideoSubTypeNone = 0; // 没有子类型 VideoSubTypeBangumi = 1; // 番剧 VideoSubTypeMovie = 2; // 电影 VideoSubTypeDocumentary = 3; // 纪录片 VideoSubTypeDomestic = 4; // 国创 VideoSubTypeTeleplay = 5; // 电视剧 } // 大会员信息 message VipInfo { // 大会员类型 int32 Type = 1; // 大会员状态 int32 status = 2; // 到期时间 int64 due_date = 3; // 标签 VipLabel label = 4; // 主题 int32 theme_type = 5; } // 大会员标签 message VipLabel { // 图片地址 string path = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/dynamic/v2/campus.proto ================================================ syntax = "proto3"; package bilibili.app.dynamic.v2; option java_multiple_files = true; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/pagination/pagination.proto"; import "bilibili/app/dynamic/common/dynamic.proto"; import "bilibili/app/dynamic/v2/dynamic.proto"; service Campus { // //rpc WaterFlowRcmd (WaterFlowRcmdReq) returns (WaterFlowRcmdResp); } // message CampusWaterFlowItem { // int32 item_type = 1; // bilibili.app.dynamic.common.ItemWHRatio wh_ratio = 2; // oneof item { WFItemDefault item_default = 3; } } // message WaterFlowRcmdReq { // int64 campus_id = 1; // int32 page = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // //CampusRcmdReqFrom from = 4; } // message WaterFlowRcmdResp { // repeated CampusWaterFlowItem items = 1; // bilibili.pagination.FeedPaginationReply offset = 2; } // message WFItemDefault { // string title = 1; // string cover = 2; // //CoverIconWithText bottom_left_1 = 3; // //CoverIconWithText bottom_left_2 = 4; // //CoverIconWithText bottom_right_1 = 5; // string uri = 6; // //RcmdReason rcmd_reason = 7; // map annotations = 8; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/dynamic/v2/dynamic.proto ================================================ syntax = "proto3"; package bilibili.app.dynamic.v2; option java_multiple_files = true; import "google/protobuf/any.proto"; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/dagw/component/avatar/v1/avatar.proto"; // v2动态, rpc 按字母顺序排列 service Dynamic { // rpc AlumniDynamics (AlumniDynamicsReq) returns (AlumniDynamicsReply); // rpc CampusBillBoard (CampusBillBoardReq) returns (CampusBillBoardReply); // rpc CampusEntryTab(CampusEntryTabReq) returns (CampusEntryTabResp); // rpc CampusFeedback(CampusFeedbackReq) returns (CampusFeedbackReply); // rpc CampusHomePages(CampusHomePagesReq) returns (CampusHomePagesReply); // rpc CampusMateLikeList(CampusMateLikeListReq) returns (CampusMateLikeListReply); // rpc CampusMngDetail(CampusMngDetailReq) returns (CampusMngDetailReply); // rpc CampusMngQuizOperate(CampusMngQuizOperateReq) returns (CampusMngQuizOperateReply); // rpc CampusMngSubmit(CampusMngSubmitReq) returns (CampusMngSubmitReply); // rpc CampusRcmd(CampusRcmdReq) returns (CampusRcmdReply); // rpc CampusRcmdFeed(CampusRcmdFeedReq) returns (CampusRcmdFeedReply); // rpc CampusRecommend(CampusRecommendReq) returns (CampusRecommendReply); // rpc CampusRedDot(CampusRedDotReq) returns (CampusRedDotReply); // rpc CampusSquare(CampusSquareReq) returns (CampusSquareReply); // rpc CampusTopicRcmdFeed(CampusTopicRcmdFeedReq) returns (CampusTopicRcmdFeedReply); // 动态通用附加卡-follow/取消follow rpc DynAdditionCommonFollow(DynAdditionCommonFollowReq) returns (DynAdditionCommonFollowReply); // 动态综合页 rpc DynAll(DynAllReq) returns (DynAllReply); // 综合页最近访问 - 个人feed流 rpc DynAllPersonal(DynAllPersonalReq) returns (DynAllPersonalReply); // 综合页最近访问 - 标记已读 rpc DynAllUpdOffset(DynAllUpdOffsetReq) returns (NoReply); // 动态详情页 rpc DynDetail(DynDetailReq) returns (DynDetailReply); // 批量动态id获取动态详情 rpc DynDetails(DynDetailsReq) returns (DynDetailsReply); // 动态发布生成临时卡 rpc DynFakeCard(DynFakeCardReq) returns (DynFakeCardReply); // rpc DynFriend(DynFriendReq) returns (DynFriendReply); // 轻浏览 rpc DynLight(DynLightReq) returns (DynLightReply); // 网关调用 - 查看更多-列表 rpc DynMixUpListViewMore(DynMixUpListViewMoreReq) returns (DynMixUpListViewMoreReply); // 关注推荐up主换一换 rpc DynRcmdUpExchange(DynRcmdUpExchangeReq) returns (DynRcmdUpExchangeReply); // rpc DynSearch(DynSearchReq) returns (DynSearchReply); // rpc DynServerDetails(DynServerDetailsReq) returns (DynServerDetailsReply); // 空间页动态 rpc DynSpace(DynSpaceReq) returns (DynSpaceRsp); // rpc DynSpaceSearchDetails(DynSpaceSearchDetailsReq) returns (DynSpaceSearchDetailsReply); // rpc DynTab(DynTabReq) returns (DynTabReply); // 动态点赞 rpc DynThumb(DynThumbReq) returns (NoReply); // 未登录页分区UP主推荐 rpc DynUnLoginRcmd(DynRcmdReq) returns (DynRcmdReply); // 动态视频页 rpc DynVideo(DynVideoReq) returns (DynVideoReply); // 视频页最近访问 - 个人feed流 rpc DynVideoPersonal(DynVideoPersonalReq) returns (DynVideoPersonalReply); // 视频页最近访问 - 标记已读 rpc DynVideoUpdOffset(DynVideoUpdOffsetReq) returns (NoReply); // rpc DynVote(DynVoteReq) returns (DynVoteReply); // rpc FeedFilter(FeedFilterReq) returns (FeedFilterReply); // rpc FetchTabSetting(NoReq) returns (FetchTabSettingReply); // rpc HomeSubscribe(HomeSubscribeReq) returns (HomeSubscribeReply); // rpc LbsPoi(LbsPoiReq) returns (LbsPoiReply); // rpc LegacyTopicFeed(LegacyTopicFeedReq) returns (LegacyTopicFeedReply); // 点赞列表 rpc LikeList(LikeListReq) returns (LikeListReply); // rpc OfficialAccounts(OfficialAccountsReq) returns (OfficialAccountsReply); // rpc OfficialDynamics(OfficialDynamicsReq) returns (OfficialDynamicsReply); // 新版动态转发点赞列表 需要登录 rpc ReactionList(ReactionListReq) returns (ReactionListReply); // 转发列表 rpc RepostList(RepostListReq) returns (RepostListRsp); // rpc SchoolRecommend(SchoolRecommendReq) returns (SchoolRecommendReply); // rpc SchoolSearch(SchoolSearchReq) returns (SchoolSearchReply); // rpc SetDecision(SetDecisionReq) returns (NoReply); // rpc SetRecentCampus(SetRecentCampusReq) returns (NoReply); // rpc SubscribeCampus(SubscribeCampusReq) returns (NoReply); // rpc TopicList(TopicListReq) returns (TopicListReply); // rpc TopicSquare(TopicSquareReq) returns (TopicSquareReply); // rpc UnfollowMatch(UnfollowMatchReq) returns (NoReply); // rpc UpdateTabSetting(UpdateTabSettingReq) returns (NoReply); } // enum AddButtonBgStyle { fill = 0; // 默认填充 stroke = 1; // 描边 gray = 2; // 置灰 } // 按钮类型 enum AddButtonType { bt_none = 0; // 占位 bt_jump = 1; // 跳转 bt_button = 2; // 按钮 } // 活动皮肤 message AdditionalActSkin { // 动画SVGA资源 string svga = 1; // 动画SVGA最后一帧图片资源 string last_image = 2; // 动画播放次数 int64 play_times = 3; } // 动态-附加卡-按钮 message AdditionalButton { // 按钮类型 AddButtonType type = 1; // jump-跳转样式 AdditionalButtonStyle jump_style = 2; // jump-跳转链接 string jump_url = 3; // button-未点样式 AdditionalButtonStyle uncheck = 4; // button-已点样式 AdditionalButtonStyle check = 5; // button-当前状态 AdditionalButtonStatus status = 6; // 按钮点击样式 AdditionalButtonClickType click_type = 7; } // 附加卡按钮点击类型 enum AdditionalButtonClickType { click_none = 0; // 通用按钮 click_up = 1; // 预约卡按钮 } // message AdditionalButtonInteractive { // 是否弹窗 string popups = 1; // 弹窗确认文案 string confirm = 2; // 弹窗取消文案 string cancel = 3; // string desc = 4; } // message AdditionalButtonShare { // int32 show = 1; // string icon = 2; // string text = 3; } // 附加卡按钮状态 enum AdditionalButtonStatus { none = 0; // uncheck = 1; // check = 2; // } // 动态-附加卡-按钮样式 message AdditionalButtonStyle { // icon string icon = 1; // 文案 string text = 2; // 按钮点击交互 AdditionalButtonInteractive interactive = 3; // 当前按钮填充样式 AddButtonBgStyle bg_style = 4; // toast文案, 当disable=1时有效 string toast = 5; // 当前按钮样式, // 0:高亮 1:置灰(按钮不可点击) DisableState disable = 6; // AdditionalButtonShare share = 7; } // 动态-附加卡-番剧卡 message AdditionalPGC { // 头部说明文案 string head_text = 1; // 标题 string title = 2; // 展示图 string image_url = 3; // 描述文字1 string desc_text_1 = 4; // 描述文字2 string desc_text_2 = 5; // 点击跳转链接 string url = 6; // 按钮 AdditionalButton button = 7; // 头部icon string head_icon = 8; // style ImageStyle style = 9; // 动态本身的类型 type string type = 10; } // enum AdditionalShareShowType { st_none = 0; // st_show = 1; // } // 枚举-动态附加卡 enum AdditionalType { additional_none = 0; // 占位 additional_type_pgc = 1; // 附加卡-追番 additional_type_goods = 2; // 附加卡-商品 additional_type_vote = 3; // 附加卡投票 additional_type_common = 4; // 附加通用卡 additional_type_esport = 5; // 附加电竞卡 additional_type_up_rcmd = 6; // 附加UP主推荐卡 additional_type_ugc = 7; // 附加卡-ugc additional_type_up_reservation = 8; // UP主预约卡 } // 动态-附加卡-专栏 message AdditionArticle { // string title = 1; // MdlDynDrawItem cover = 2; // string desc_text_left = 3; // string desc_text_right = 4; // string uri = 5; // string card_type = 6; } // 动态-附加卡-通用卡 message AdditionCommon { // 头部说明文案 string head_text = 1; // 标题 string title = 2; // 展示图 string image_url = 3; // 描述文字1 string desc_text_1 = 4; // 描述文字2 string desc_text_2 = 5; // 点击跳转链接 string url = 6; // 按钮 AdditionalButton button = 7; // 头部icon string head_icon = 8; // style ImageStyle style = 9; // 动态本身的类型 type string type = 10; // 附加卡类型 string card_type = 11; // ogv manga } // 动态-附加卡-电竞卡 message AdditionEsport { // 电竞类型 EspaceStyle style = 1; oneof item { // moba类 AdditionEsportMoba addition_esport_moba = 2; } // 动态本身的类型 type string type = 3; // 附加卡类型 string card_type = 4; // ogv manga } // 动态-附加卡-电竞卡-moba类 message AdditionEsportMoba { // 头部说明文案 string head_text = 1; // 标题 string title = 2; // 战队列表 repeated MatchTeam match_team = 3; // 比赛信息 AdditionEsportMobaStatus addition_esport_moba_status = 4; // 卡片跳转 string uri = 5; // 按钮 AdditionalButton button = 6; // 副标题 string sub_title = 7; // 动态本身的类型 type string type = 10; // 附加卡类型 string card_type = 11; // 附加卡图标 string head_icon = 12; } // 动态-附加卡-电竞卡-moba类-比赛信息 message AdditionEsportMobaStatus { // 文案类 repeated AdditionEsportMobaStatusDesc addition_esport_moba_status_desc = 1; // 比赛状态文案 string title = 2; // 比赛状态状态 int32 status = 3; // 日间色值 string color = 4; // 夜间色值 string night_color = 5; } // 动态-附加卡-电竞卡-moba类-比赛信息-文案类 message AdditionEsportMobaStatusDesc { // 文案 string title = 1; // 日间色值 string color = 2; // 夜间色值 string night_color = 3; } // 动态-附加卡-商品卡 message AdditionGoods { // 推荐文案 string rcmd_desc = 1; // 商品信息 repeated GoodsItem goods_items = 2; // 附加卡类型 string card_type = 3; // 头部icon string icon = 4; // 商品附加卡整卡跳转 string uri = 5; // 商品类型 // 1:淘宝 2:会员购,注:实际是获取的goods_items里面的第一个source_type int32 source_type = 6; // int32 jump_type = 7; // string app_name = 8; // string ad_mark_icon = 9; } // 动态-附加卡-直播附加卡 message AdditionLiveRoom { // string title = 1; // string cover = 2; // VideoBadge badge = 3; // CoverIconWithText desc_text_upper = 4; // string desc_text_lower = 5; // string uri = 6; // string card_type = 7; } // 动态-附加卡-UGC视频附加卡 message AdditionUgc { // 说明文案 string head_text = 1; // 稿件标题 string title = 2; // 封面 string cover = 3; // 描述文字1 string desc_text_1 = 4; // 描述文字2 string desc_text_2 = 5; // 接秒开 string uri = 6; // 时长 string duration = 7; // 标题支持换行-标题支持单行和双行,本期不支持填充up昵称,支持双行展示,字段默认为true bool line_feed = 8; // 附加卡类型 string card_type = 9; } // up主预约发布卡 message AdditionUP { // 标题 string title = 1; // 高亮文本,描述文字1 HighlightText desc_text_1 = 2; // 描述文字2 string desc_text_2 = 3; // 点击跳转链接 string url = 4; // 按钮 AdditionalButton button = 5; // 附加卡类型 string card_type = 6; // 预约人数(用于预约人数变化) int64 reserve_total = 7; // 活动皮肤 AdditionalActSkin act_skin = 8; // 预约id int64 rid = 9; // int32 lottery_type = 10; // HighlightText desc_text3 = 11; // int64 up_mid = 12; // AdditionUserInfo user_info = 13; // string dynamic_id = 14; // bool show_text2 = 15; // int64 dyn_type = 16; // string business_id = 17; // string badge_text = 18; // bool is_premiere = 19; } // message AdditionUserInfo { // string name = 1; // string face = 2; } // 动态-附加卡-投票 message AdditionVote { // 封面图 string image_url = 1; // 标题 string title = 2; // 展示项1 string text_1 = 3; // button文案 string button_text = 4; // 点击跳转链接 string url = 5; } // 动态模块-投票 message AdditionVote2 { // 投票类型 AdditionVoteType addition_vote_type = 1; // 投票ID int64 vote_id = 2; // 标题 string title = 3; // 已过期: xxx人参与· 投票已过期。button 展示去查看 // 未过期: xxx人参与· 剩xx天xx时xx分。button展示去投票 string label = 4; // 剩余时间 int64 deadline = 5; // 生效文案 string open_text = 6; // 过期文案 string close_text = 7; // 已投票 string voted_text = 8; // 投票状态 AdditionVoteState state = 9; // 投票信息 oneof item { // AdditionVoteWord addition_vote_word = 10; // AdditionVotePic addition_vote_pic = 11; // AdditionVoteDefaule addition_vote_defaule = 12; } // 业务类型 // 0:动态投票 1:话题h5组件 int32 biz_type = 13; // 投票总人数 int64 total = 14; // 附加卡类型 string card_type = 15; // 异常提示 string tips = 16; // 跳转地址 string uri = 17; // 是否投票 bool is_voted = 18; // 投票最多多选个数,单选为1 int32 choice_cnt = 19; // 是否默认选中分享到动态 bool defaule_select_share = 20; } // 外露投票 message AdditionVoteDefaule { // 图片 多张 repeated string cover = 1; } // 外露图片类型 message AdditionVotePic { // 图片投票详情 repeated AdditionVotePicItem item = 1; } // 图片投票详情 message AdditionVotePicItem { // 选项索引,从1开始 int32 opt_idx = 1; // 图片 string cover = 2; // 选中状态 bool is_vote = 3; // 人数 int32 total = 4; // 占比 double persent = 5; // 标题文案 string title = 6; // 是否投票人数最多的选项 bool is_max_option = 7; } // 投票状态 enum AdditionVoteState { addition_vote_state_none = 0; // addition_vote_state_open = 1; // addition_vote_state_close = 2; // } // 投票类型 enum AdditionVoteType { addition_vote_type_none = 0; // addition_vote_type_word = 1; // addition_vote_type_pic = 2; // addition_vote_type_default = 3; // } // 外露文字类型 message AdditionVoteWord { // 外露文字投票详情 repeated AdditionVoteWordItem item = 1; } // 外露文字投票详情 message AdditionVoteWordItem { // 选项索引,从1开始 int32 opt_idx = 1; // 文案 string title = 2; // 选中状态 bool is_vote = 3; // 人数 int32 total = 4; // 占比 double persent = 5; // 是否投票人数最多的选项 bool is_max_option = 6; } // 综合页请求广告所需字段,由客户端-网关透传 message AdParam { // 综合页请求广告所需字段,由客户端-网关透传 string ad_extra = 1; // request_id string request_id = 2; } // message AlumniDynamicsReply { // repeated DynamicItem list = 1; // string toast = 2; } // message AlumniDynamicsReq { // int64 campus_id = 1; // int32 first_time = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // int32 local_time = 4; // int32 page = 5; // int32 from_type = 6; } // message CampusBannerInfo { // string image = 1; // string jump_url = 2; } // message CampusBillboardInternalReq { // int64 mid = 1; // int64 campus_id = 2; // string version_code = 3; } // message CampusBillBoardReply { // string title = 1; // string help_uri = 2; // string campus_name = 3; // int64 build_time = 4; // string version_code = 5; // repeated OfficialItem list = 6; // string share_uri = 7; // int32 bind_notice = 8; // string update_toast = 9; // int64 campus_id = 10; // CampusFeatureProgress open_progress = 11; } // message CampusBillBoardReq { // int64 campus_id = 1; // string version_code = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // CampusReqFromType from_type = 4; } // message CampusEntryTabReq { // int64 campus_id = 1; } // message CampusEntryTabResp { // CampusEntryType entry_type = 1; } // enum CampusEntryType { // NONE = 0; // ENTRY_DYNAMIC = 1; // ENTRY_HOME = 2; } // message CampusFeatureProgress { // int64 progress_full = 1; // int64 progress_achieved = 2; // string desc_title = 3; // string desc_1 = 4; // CampusLabel btn = 5; } // message CampusFeedbackInfo { // int32 biz_type = 1; // int64 biz_id = 2; // int64 campus_id = 3; // string reason = 4; } // message CampusFeedbackReply { // string message = 1; } // message CampusFeedbackReq { // repeated CampusFeedbackInfo infos = 1; // int32 from = 2; } // message CampusHomePagesReply { // CampusRcmdTop top = 1; // CampusTop campus_top = 2; // int32 page_type = 3; } // message CampusHomePagesReq { // int64 campus_id = 1; // string campus_name = 2; // double lat = 3; // double lng = 4; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 5; // int32 page_type = 6; } enum CampusRcmdReqFrom { CAMPUS_RCMD_FROM_UNKNOWN = 0; CAMPUS_RCMD_FROM_HOME_UN_OPEN = 1; CAMPUS_RCMD_FROM_VISIT_OTHER = 2; CAMPUS_RCMD_FROM_HOME_MOMENT = 3; CAMPUS_RCMD_FROM_DYN_MOMENT = 4; CAMPUS_RCMD_FROM_PAGE_SUBORDINATE_MOMENT = 5; } // enum CampusHomePageType { // PAGE_MAJOR = 0; // PAGE_SUBORDINATE = 1; // PAGE_MAJOR_DETAIL = 2; } // message CampusHomeRcmdTopic { // ModuleTitle title = 1; // repeated TopicItem topic = 2; } // message CampusInfo { // int64 campus_id = 1; // string campus_name = 2; // string desc = 3; // int64 online = 4; // string url = 5; } // message CampusLabel { // string text = 1; // string url = 2; // string desc = 3; } // message CampusMateLikeListReply { // repeated ModuleAuthor list = 1; } // message CampusMateLikeListReq { // int64 dynamic_id = 1; // CampusReqFromType from_type = 2; } // enum CampusMngAuditStatus { // campus_mng_audit_none = 0; // campus_mng_audit_in_process = 1; // campus_mng_audit_failed = 2; } // message CampusMngBadge { // string title = 1; // string badge_url = 2; // string upload_hint_msg = 3; } // message CampusMngBasicInfo { // int64 campus_id = 1; // string campus_name = 2; // string hint_msg = 3; } // message CampusMngDetailReply { // repeated CampusMngItem items = 1; // string top_hint_bar_msg = 2; // string bottom_submit_hint_msg = 3; // int64 campus_id = 4; // string campus_name = 5; } // message CampusMngDetailReq { // int64 campus_id = 1; } // message CampusMngItem { // int32 audit_status = 1; // string audit_message = 2; // int32 item_type = 3; // string mng_item_id = 4; // bool is_del = 5; // Oneof field: oneof item { // CampusMngBasicInfo basic_info = 6; // CampusMngBadge badge = 7; // string slogan = 8; // CampusMngQuiz quiz = 9; } } // enum CampusMngItemType { // campus_mng_none = 0; // campus_mng_basic_info = 1; // campus_mng_badge = 2; // campus_mng_slogan = 3; // campus_mng_quiz = 4; } // message CampusMngQuiz { // string title = 1; // CampusLabel more_label = 2; // string add_label = 3; // string submit_label = 4; // int64 quiz_count = 5; } // enum CampusMngQuizAction { // campus_mng_quiz_act_list = 0; // campus_mng_quiz_act_add = 1; // campus_mng_quiz_act_del = 2; } // message CampusMngQuizDetail { // int64 quiz_id = 1; // string question = 2; // string correct_answer = 3; // repeated string wrong_answer_list = 4; // int32 audit_status = 5; // string audit_message = 6; } // message CampusMngQuizOperateReply { // string toast = 1; // repeated CampusMngQuizDetail quiz = 2; // int64 quiz_total = 3; } // message CampusMngQuizOperateReq { // int32 action = 1; // int64 campus_id = 2; // repeated CampusMngQuizDetail quiz = 3; } // message CampusMngSlogan { // string title = 1; // string slogan = 2; // string input_hint_msg = 3; } // message CampusMngSubmitReply { // string toast = 1; } // message CampusMngSubmitReq { // int64 campus_id = 1; // repeated CampusMngItem modified_items = 2; } // message CampusNoticeInfo { // string title = 1; // string desc = 2; // CampusLabel button = 3; } // enum CampusOnlineStatus { // campus_online_offline = 0; // campus_online_online = 1; } // message CampusRcmdFeedReply { // repeated DynamicItem list = 1; // string toast = 2; // GuideBarInfo guide_bar = 3; // bool has_more = 4; // bool update = 5; } // message CampusRcmdFeedReq { // int64 campus_id = 1; // int32 first_time = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // int32 local_time = 4; // int32 page = 5; // int32 scroll = 6; // string view_dyn_id = 7; // CampusReqFromType from_type = 8; } // message CampusRcmdInfo { // string title = 1; // repeated CampusRcmdItem items = 2; } // message CampusRcmdItem { // string title = 1; // repeated RcmdItem items = 2; // int64 campus_id = 3; // CampusLabel entry_label = 4; } // message CampusRcmdReply { // CampusRcmdTop top = 1; // CampusRcmdInfo rcmd = 2; // CampusTop campus_top = 3; // int32 page_type = 4; // int32 jump_home_pop = 5; } // message CampusRcmdReq { // int64 campus_id = 1; // string campus_name = 2; // double lat = 3; // double lng = 4; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 5; // CampusReqFromType from_type = 6; // CampusHomePageType page_type = 7; } // message CampusRcmdTop { // int64 campus_id = 1; // string campus_name = 2; // string title = 3; // string desc = 4; // int32 type = 5; // RcmdTopButton button = 6; // CampusLabel switch_label = 7; // CampusLabel notice_label = 8; // string desc2 = 9; // string desc3 = 10; // CampusLabel invite_label = 11; // CampusLabel reserve_label = 12; // int64 reserve_number = 13; // int64 max_reserve = 14; // CampusLabel school_label = 15; // CampusLabel mng_label = 16; // CampusHomeRcmdTopic rcmd_topic = 17; // bool audit_before_open = 18; // string audit_message = 19; } // message CampusRecommendReply { // repeated RcmdItem items = 1; // bool has_more = 2; } // message CampusRecommendReq { // int64 campus_id = 1; // int64 page_no = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // //CampusRcmdReqFrom from = 4; } // message CampusRedDotReply { // int32 red_dot = 1; } // message CampusRedDotReq { // int64 campus_id = 1; // CampusReqFromType from_type = 2; } // enum CampusReqFromType { // DYNAMIC = 0; // HOME = 1; } // message CampusShowTabInfo { // string name = 1; // string url = 2; // int32 type = 3; // int32 red_dot = 4; // string icon_url = 5; } // message CampusSquareReply { // string title = 1; // repeated RcmdCampusBrief list = 2; // CampusLabel button = 3; } // message CampusSquareReq { // int64 campus_id = 1; // double lat = 2; // double lng = 3; } // enum CampusTabType { campus_none = 0; // campus_school = 1; // campus_dynamic = 2; // campus_account = 3; // campus_billboard = 4; // campus_topic = 5; // campues_other = 6; // } // message CampusTop { // int64 campus_id = 1; // string campus_name = 2; // repeated CampusShowTabInfo tabs = 3; // CampusLabel switch_label = 4; // string title = 5; // repeated CampusBannerInfo banner = 6; // CampusLabel invite_label = 7; // CampusNoticeInfo notice = 8; // TopicSquareInfo topic_square = 9; // string campus_badge = 10; // string campus_background = 11; // string campus_motto = 12; // CampusLabel mng_entry = 13; // string campus_intro = 14; // string campus_name_link = 15; // string bottom_left_text = 16; } // message CampusTopicRcmdFeedReply { // repeated DynamicItem list = 1; // string toast = 2; // bool has_more = 3; // string offset = 4; // IconButton join_discuss = 5; } // message CampusTopicRcmdFeedReq { // int64 campus_id = 1; // string offset = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // int32 local_time = 4; // CampusReqFromType from_type = 5; } // message CardParagraph { // ModuleAdditional additional_card = 1; // string biz_id = 3; // LinkNodeType biz_type = 2; } // 动态卡片列表 message CardVideoDynList { // 动态列表 repeated DynamicItem list = 1; // 更新的动态数 int64 update_num = 2; // 历史偏移 string history_offset = 3; // 更新基础信息 string update_baseline = 4; // 是否还有更多数据 bool has_more = 5; } // 视频页-我的追番 message CardVideoFollowList { // 查看全部(跳转链接) string view_all_link = 1; // 追番列表 repeated FollowListItem list = 2; } // 视频页-最近访问 message CardVideoUpList { // 标题展示文案 string title = 1; // up主列表 repeated UpListItem list = 2; // 服务端生成的透传上报字段 string footprint = 3; // 直播数 int32 show_live_num = 4; // 跳转label UpListMoreLabel more_label = 5; // 标题开关(综合页) int32 title_switch = 6; // 是否展示右上角查看更多label bool show_more_label = 7; // 是否在快速消费页查看更多按钮 bool show_in_personal = 8; // 是否展示右侧查看更多按钮 bool show_more_button = 9; // repeated UpListItem list_second = 10; } // message ChannelInfo { // int64 channel_id = 1; // string channel_name = 2; // string desc = 3; // bool is_atten = 4; // string type_icon = 5; // repeated RcmdItem items = 6; // string icon = 7; // string jump_uri = 8; } // 评论外露展示项 message CmtShowItem { // 用户mid int64 uid = 1; // 用户昵称 string uname = 2; // 点击跳转链接 string uri = 3; // 评论内容 string comment = 4; } // message Colors { // string color_day = 1; // string color_night = 2; } // 精选评论区 message CommentDetail { // 该功能能不能用 bool can_modify = 1; // up关闭评论区功能 1允许关闭 0允许开放 // 精选评论区功能 1允许停止评论精选 0允许评论精选 int64 status = 2; } // message CommonShareCardInfo { // int64 sketch_id = 1; // int64 biz_type = 2; // int64 biz_id = 3; } // message Config { // bool story_vertical_exp = 1; // int64 detail_view_bits = 2; } // enum CoverIcon { cover_icon_none = 0; // 占位 啥都不展示 cover_icon_play = 1; // 播放icon cover_icon_danmaku = 2; // cover_icon_up = 3; // cover_icon_vt = 4; // ? 竖屏模式 icon } // message CoverIconWithText { // int32 icon = 1; // string text = 2; } // 装扮卡片-粉丝勋章信息 message DecoCardFan { // 是否是粉丝 int32 is_fan = 1; // 数量 int32 number = 2; // 数量 str string number_str = 3; // 颜色 string color = 4; } // 装扮卡片 message DecorateCard { // 装扮卡片id int64 id = 1; // 装扮卡片链接 string card_url = 2; // 装扮卡片点击跳转链接 string jump_url = 3; // 粉丝样式 DecoCardFan fan = 4; } // 文本描述 message Description { // 文本内容 string text = 1; // 文本类型 DescType type = 2; // 点击跳转链接 string uri = 3; // emoji类型 EmojiType emoji_type = 4; // 商品类型 string goods_type = 5; // 前置Icon string icon_url = 6; // icon_name string icon_name = 7; // 资源ID string rid = 8; // 商品卡特殊字段 ModuleDescGoods goods = 9; // 文本原始文案 string orig_text = 10; // int32 emoji_size = 11; // EmojiSizeSpec emoji_size_spec = 12; } // 文本类型 enum DescType { desc_type_none = 0; // 占位 desc_type_text = 1; // 文本 desc_type_aite = 2; // @ desc_type_lottery = 3; // 抽奖 desc_type_vote = 4; // 投票 desc_type_topic = 5; // 话题 desc_type_goods = 6; // 商品 desc_type_bv = 7; // bv desc_type_av = 8; // av desc_type_emoji = 9; // 表情 desc_type_user = 10; // 外露用户 desc_type_cv = 11; // 专栏 desc_type_vc = 12; // 小视频 desc_type_web = 13; // 网址 desc_type_taobao = 14; // 淘宝 desc_type_mail = 15; // 邮箱 desc_type_ogv_season = 16; // 番剧season desc_type_ogv_ep = 17; // 番剧ep desc_type_search_word = 18; // } // 尺寸信息 message Dimension { // int64 height = 1; // int64 width = 2; // int64 rotate = 3; } // enum DisableState { highlight = 0; // 高亮 gary = 1; // 置灰(按钮不可点击) } // 动态通用附加卡-follow/取消follow-响应 message DynAdditionCommonFollowReply { // AdditionalButtonStatus status = 1; } // 动态通用附加卡-follow/取消follow-请求 message DynAdditionCommonFollowReq { // AdditionalButtonStatus status = 1; // string dyn_id = 2; // string card_type = 3; } // 最近访问-个人feed流列表-返回 message DynAllPersonalReply { // 动态列表 repeated DynamicItem list = 1; // 偏移量 string offset = 2; // 是否还有更多数据 bool has_more = 3; // 已读进度 string read_offset = 4; // 关注状态 Relation relation = 5; // 顶部预约卡 TopAdditionUP addition_up = 6; // string title = 7; // string title_sub = 8; } // 最近访问-个人feed流列表-请求 message DynAllPersonalReq { // 被访问者的 UID int64 host_uid = 1; // 偏移量 第一页可传空 string offset = 2; // 标明下拉几次 int32 page = 3; // 是否是预加载 默认是1;客户端预加载。1:是预加载,不更新已读进度,不会影响小红点;0:非预加载,更新已读进度 int32 is_preload = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 6; // 服务端生成的透传上报字段 string footprint = 7; // 来源 string from = 8; // 秒开用 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 9; // string personal_extra = 10; } // 动态综合页-响应 message DynAllReply { // 卡片列表 DynamicList dynamic_list = 1; // 顶部up list CardVideoUpList up_list = 2; // 话题广场 TopicList topic_list = 3; // 无关注推荐 Unfollow unfollow = 4; // 分区UP推荐 DynRegionRcmd region_rcmd = 5; // Config config = 6; } // 动态综合页-请求 message DynAllReq { // 透传 update_baseline string update_baseline = 1; // 透传 history_offset string offset = 2; // 向下翻页数 int32 page = 3; // 刷新方式 1向上刷新 2向下翻页 Refresh refresh_type = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 综合页当前更新的最大值 string assist_baseline = 6; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 7; // 推荐up主入参(new的时候传) RcmdUPsParam rcmd_ups_param = 8; // 广告参数 AdParam ad_param = 9; // 是否冷启 int32 cold_start = 10; // 来源 string from = 11; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 12; // int64 tab_recall_uid = 13; // int32 tab_recall_type = 14; } // 最近访问-标记已读-请求 message DynAllUpdOffsetReq { // 被访问者的UID int64 host_uid = 1; // 用户已读进度 string read_offset = 2; // 服务端生成的透传上报字段 string footprint = 3; // string personal_extra = 4; } // 动态卡片 message DynamicItem { // 动态卡片类型 DynamicType card_type = 1; // 转发类型下,源卡片类型 DynamicType item_type = 2; // 模块内容 repeated Module modules = 3; // 操作相关字段 Extend extend = 4; // 该卡片下面是否含有折叠卡 int32 has_fold = 5; // 透传到客户端的埋点字段。 string server_info = 6; } //动态卡片列表 message DynamicList { // 动态列表 repeated DynamicItem list = 1; // 更新的动态数 int64 update_num = 2; // 历史偏移 string history_offset = 3; // 更新基础信息 string update_baseline = 4; // 是否还有更多数据 bool has_more = 5; } // 枚举-动态类型 enum DynamicType { dyn_none = 0; // 占位 forward = 1; // 转发 av = 2; // 稿件: ugc、小视频、短视频、UGC转PGC pgc = 3; // pgc:番剧、PGC番剧、PGC电影、PGC电视剧、PGC国创、PGC纪录片 courses = 4; // 付费更新批次 fold = 5; // 折叠 word = 6; // 纯文字 draw = 7; // 图文 article = 8; // 专栏 原仅phone端 music = 9; // 音频 原仅phone端 common_square = 10; // 通用卡 方形 common_vertical = 11; // 通用卡 竖形 live = 12; // 直播卡 只有转发态 medialist = 13; // 播单 原仅phone端 只有转发态 courses_season = 14; // 付费更新批次 只有转发态 ad = 15; // 广告卡 applet = 16; // 小程序卡 subscription = 17; // 订阅卡 live_rcmd = 18; // 直播推荐卡 banner = 19; // 通栏 ugc_season = 20; // 合集卡 subscription_new = 21; // 新订阅卡 story = 22; // topic_rcmd = 23; // cour_up = 24; // topic_set = 25; // notice = 26; // text_notice = 27; // } // 动态详情页-响应 message DynDetailReply { // 动态详情 DynamicItem item = 1; } // 动态详情页-请求 message DynDetailReq { // up主uid int64 uid = 1; // 动态ID string dynamic_id = 2; // 动态类型 int64 dyn_type = 3; // 业务方资源id int64 rid = 4; // 广告参数 AdParam ad_param = 5; // From来源 string from = 6; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 7; // 分享id string share_id = 8; // 分享类型 // 1:文字 2:图片 3:链接 4:视频 5:音频 int32 share_mode = 9; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 10; // pattern string pattern = 11; // Config config = 12; } // 批量动态id获取动态详情-响应 message DynDetailsReply { // 动态列表 repeated DynamicItem list = 1; } // 批量动态id获取动态详情-请求 message DynDetailsReq { // 动态id string dynamic_ids = 1; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 2; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 3; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; // Config config = 5; } // 动态小卡类型 enum DynExtendType { dyn_ext_type_none = 0; // 占位 dyn_ext_type_topic = 1; // 话题小卡 dyn_ext_type_lbs = 2; // lbs小卡 dyn_ext_type_hot = 3; // 热门小卡 dyn_ext_type_game = 4; // 游戏小卡 dyn_ext_type_common = 5; // 通用小卡 dyn_ext_type_biliCut = 6; // 必剪小卡 dyn_ext_type_ogv = 7; // ogv小卡 dyn_ext_type_auto_ogv = 8; // 自动附加ogv小卡 } // 动态发布生成临时卡-响应 message DynFakeCardReply { // 动态卡片 DynamicItem item = 1; } // 动态发布生成临时卡-请求 message DynFakeCardReq { //卡片内容json string string content = 1; } // message DynFeatureGate { // bool enhanced_interaction = 1; } // message DynFriendReply { // repeated DynamicItem dyn_list = 1; // bool has_more = 2; // string offset = 3; } // message DynFriendReq { // string offset = 1; // int32 local_time = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; } // 轻浏览-响应 message DynLightReply { // 卡片列表 DynamicList dynamic_list = 1; } // 轻浏览-请求 message DynLightReq { // 透传 history_offset string history_offset = 1; // 向下翻页数 int32 page = 2; // 来源 string from = 3; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 5; // int32 from_type = 6; // int64 fake_uid = 7; } // 查看更多-列表-响应 message DynMixUpListViewMoreReply { // repeated MixUpListItem items = 1; // string search_default_text = 2; // 排序类型列表 repeated SortType sort_types = 3; // 是否展示更多的排序策略 bool show_more_sort_types = 4; // 默认排序策略 int32 default_sort_type = 5; } // 查看更多-请求 message DynMixUpListViewMoreReq { // 排序策略 // 1:推荐排序 2:最常访问 3:最近关注,其他值为默认排序 int32 sort_type = 1; } // 动态模块类型 enum DynModuleType { module_none = 0; // 占位 module_author = 1; // 发布人模块 module_dispute = 2; // 争议小黄条 module_desc = 3; // 描述文案 module_dynamic = 4; // 动态卡片 module_forward = 5; // 转发模块 module_likeUser = 6; // 点赞用户(废弃) module_extend = 7; // 小卡模块 module_additional = 8; // 附加卡 module_stat = 9; // 计数信息 module_fold = 10; // 折叠 module_comment = 11; // 评论外露(废弃) module_interaction = 12; // 外露交互模块(点赞、评论) module_author_forward = 13; // 转发卡的发布人模块 module_ad = 14; // 广告卡模块 module_banner = 15; // 通栏模块 module_item_null = 16; // 获取物料失败模块 module_share_info = 17; // 分享组件 module_recommend = 18; // 相关推荐模块 module_stat_forward = 19; // 转发卡计数信息 module_top = 20; // 顶部模块 module_bottom = 21; // 底部模块 module_story = 22; // module_topic = 23; // module_topic_details_ext = 24; // module_top_tag = 25; // module_topic_brief = 26; // module_title = 27; // module_button = 28; module_notice = 29; module_opus_summary = 30; module_copyright = 31; module_paragraph = 32; module_blocked = 33; module_text_notice = 34; module_opus_collection = 35; } // 推荐页-响应 message DynRcmdReply { // 推荐页返回参数 DynRegionRcmd region_rcmd = 1; // DynamicList dynamic_list = 2; } // 推荐页-请求 message DynRcmdReq { // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 1; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 2; // int64 fake_uid = 3; // bool is_refresh = 4; } // 关注推荐up主换一换-响应 message DynRcmdUpExchangeReply { // 无关注推荐 Unfollow unfollow = 1; } // 关注推荐up主换一换-请求 message DynRcmdUpExchangeReq { // 登录用户id int64 uid = 1; // 上一次不感兴趣的ts,单位:秒;该字段透传给搜索 int64 dislikeTs = 2; // 需要与服务端确认或参照客户端现有参数 string from = 3; } // 推荐页返回参数 message DynRegionRcmd { // 分区推荐项目列表 repeated DynRegionRcmdItem items = 1; // 分区聚类推荐选项 RcmdOption opts = 2; } // 分区推荐项目 message DynRegionRcmdItem { // 分区id int64 rid = 1; // 标题 string title = 2; // 推荐模块 repeated ModuleRcmd items = 3; } // message DynScreenTab { // string title = 1; // string name = 2; // bool default_tab = 3; // bool strategy_show_on_entrance = 4; // bool strategy_show_on_refresh = 5; // bool strategy_show_on_pull_up = 6; } // message DynSearchReply { // SearchChannel channel_info = 1; // SearchTopic search_topic = 2; // SearchInfo search_info = 3; } // message DynSearchReq { // string keyword = 1; // int32 page = 2; // int32 local_time = 3; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; } // message DynServerDetailsReply { // map items = 1; } // message DynServerDetailsReq { // int32 local_time = 2; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // string mobi_app = 4; // string device = 5; // string buvid = 6; // int64 build = 7; // int64 mid = 8; // string platform = 9; // bool is_master = 10; // repeated int64 top_dynamic_ids = 11; } // 空间页动态-请求 message DynSpaceReq { // 被访问者,也就是空间主人的uid int64 host_uid = 1; // 动态偏移history_offset string history_offset = 2; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 3; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 4; // 向下翻页数,默认从1开始 int64 page = 5; // 来源,空间页:space,直播tab:live string from = 6; } // 空间页动态-响应 message DynSpaceRsp { // 卡片列表 repeated DynamicItem list = 1; // 历史偏移 string history_offset = 2; // 是否还有更多数据 bool has_more = 3; } // message DynSpaceSearchDetailsReply { // map items = 1; } // message DynSpaceSearchDetailsReq { // repeated string search_words = 2; // int32 local_time = 3; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; // string mobi_app = 5; // string device = 6; // string buvid = 7; // int64 build = 8; // int64 mid = 9; // string platform = 10; // string ip = 11; // int32 net_type = 12; // int32 tf_type = 13; } // message DynTab { // string title = 1; // string uri = 2; // string bubble = 3; // int32 red_point = 4; // int64 city_id = 5; // int32 is_popup = 6; // Popup popup = 7; // bool default_tab = 8; // string sub_title = 9; // string anchor = 10; // string internal_test = 11; // int32 type = 12; // DynTab back_up = 13; } // message DynTabReply { // repeated DynTab dyn_tab = 1; // repeated DynScreenTab screen_tab = 2; } // message DynTabReq { // int32 teenagers_mode = 1; // CampusReqFromType from_type = 2; } // 动态点赞-请求 message DynThumbReq { // 用户uid int64 uid = 1; // 动态id string dyn_id = 2; // 动态类型(透传extend中的dyn_type) int64 dyn_type = 3; // 业务方资源id string rid = 4; // 点赞类型 ThumbType type = 5; } // 最近访问-个人feed流列表-响应 message DynVideoPersonalReply { // 动态列表 repeated DynamicItem list = 1; // 偏移量 string offset = 2; // 是否还有更多数据 bool has_more = 3; // 已读进度 string read_offset = 4; // 关注状态 Relation relation = 5; // 顶部预约卡 TopAdditionUP addition_up = 6; // string title = 7; // string title_sub = 8; } // 最近访问-个人feed流列表-请求 message DynVideoPersonalReq { // 被访问者的 UID int64 host_uid = 1; // 偏移量 第一页可传空 string offset = 2; // 标明下拉几次 int32 page = 3; // 是否是预加载 int32 is_preload = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 6; // 服务端生成的透传上报字段 string footprint = 7; // 来源 string from = 8; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 9; // int64 pegasus_avid = 10; // string personal_extra = 11; } // 动态视频页-响应 message DynVideoReply { // 卡片列表 CardVideoDynList dynamic_list = 1; // 动态卡片 CardVideoUpList video_up_list = 2; // 视频页-我的追番 CardVideoFollowList video_follow_list = 3; } // 动态视频页-请求 message DynVideoReq { // 透传 update_baseline string update_baseline = 1; // 透传 history_offset string offset = 2; // 向下翻页数 int32 page = 3; // 刷新方式 // 1:向上刷新 2:向下翻页 Refresh refresh_type = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 综合页当前更新的最大值 string assist_baseline = 6; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 7; // 来源 string from = 8; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 9; } // 最近访问-标记已读-请求 message DynVideoUpdOffsetReq { // 被访问者的UID int64 host_uid = 1; // 用户已读进度 string read_offset = 2; // 服务端生成的透传上报字段 string footprint = 3; // string personal_extra = 4; } // 投票操作-响应 message DynVoteReply { // 投票详情 AdditionVote2 item = 1; // 投票操作返回状态 string toast = 2; } // 投票操作-请求 message DynVoteReq { // 投票ID int64 vote_id = 1; // 选项索引数组 repeated int64 votes = 2; // 状态 VoteStatus status = 3; // 动态ID string dynamic_id = 4; // 是否分享 bool share = 5; } // message EmojiSizeSpec { // int64 width = 1; } // 表情包类型 enum EmojiType { emoji_none = 0; // 占位 emoji_old = 1; // emoji旧类型 emoji_new = 2; // emoji新类型 vip = 3; // 大会员表情 } // message EmoteNode { // string emote_url = 2; // EmoteSize emote_width = 3; // ImgInlineCfg inline_img_cfg = 5; // bool is_inline_img = 4; // WordNode raw_text = 1; } // message EmoteSize { // double width = 1; // int32 emoji_size = 2; } // 附加大卡-电竞卡样式 enum EspaceStyle { moba = 0; // moba类 } // 扩展字段,用于动态部分操作使用 message Extend { // 动态id string dyn_id_str = 1; // 业务方id string business_id = 2; // 源动态id string orig_dyn_id_str = 3; // 转发卡:用户名 string orig_name = 4; // 转发卡:图片url string orig_img_url = 5; // 转发卡:文字内容 repeated Description orig_desc = 6; // 填充文字内容 repeated Description desc = 7; // 被转发的源动态类型 DynamicType orig_dyn_type = 8; // 分享到站外展示类型 string share_type = 9; // 分享的场景 string share_scene = 10; // 是否快速转发 bool is_fast_share = 11; // r_type 分享和转发 int32 r_type = 12; // 数据源的动态类型 int64 dyn_type = 13; // 用户id int64 uid = 14; // 卡片跳转 string card_url = 15; // 透传字段 google.protobuf.Any source_content = 16; // 转发卡:用户头像 string orig_face = 17; // 评论跳转 ExtendReply reply = 18; // string track_id = 19; // ModuleOpusSummary opus_summary = 20; // OnlyFansProperty only_fans_property = 21; // DynFeatureGate feature_gate = 22; // bool is_in_audit = 23; // map history_report = 24; } // 评论扩展 message ExtendReply { // 基础跳转地址 string uri = 1; // 参数部分 repeated ExtendReplyParam params = 2; } // 评论扩展参数部分 message ExtendReplyParam { // 参数名 string key = 1; // 参数值 string value = 2; } // 动态-拓展小卡模块-通用小卡 message ExtInfoCommon { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // poiType int32 poi_type = 4; // 类型 DynExtendType type = 5; // 客户端埋点用 string sub_module = 6; // 行动点文案 string action_text = 7; // 行动点链接 string action_url = 8; // 资源rid int64 rid = 9; // 轻浏览是否展示 bool is_show_light = 10; } // 动态-拓展小卡模块-游戏小卡 message ExtInfoGame { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 动态-拓展小卡模块-热门小卡 message ExtInfoHot { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 动态-拓展小卡模块-lbs小卡 message ExtInfoLBS { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // poiType int32 poi_type = 4; } // 动态-拓展小卡模块-ogv小卡 message ExtInfoOGV { // ogv小卡 repeated InfoOGV info_ogv = 1; } // 动态-拓展小卡模块-话题小卡 message ExtInfoTopic { // 标题-话题名 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // message FeedFilterReply { // string offset = 1; // bool has_more = 2; // repeated DynamicItem list = 3; } // message FeedFilterReq { // string offset = 1; // string tab = 2; // int32 local_time = 3; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; // AdParam ad_param = 5; // int32 cold_start = 6; // int64 page = 7; } // message FetchTabSettingReply { // int32 status = 1; } // 折叠类型 enum FoldType { FoldTypeZore = 0; // 占位 FoldTypePublish = 1; // 用户发布折叠 FoldTypeFrequent = 2; // 转发超频折叠 FoldTypeUnite = 3; // 联合投稿折叠 FoldTypeLimit = 4; // 动态受限折叠 FoldTypeTopicMerged = 5; } // 视频页-我的追番-番剧信息 message FollowListItem { // season_id int64 season_id = 1; // 标题 string title = 2; // 封面图 string cover = 3; // 跳转链接 string url = 4; // new_ep NewEP new_ep = 5; // 子标题 string sub_title = 6; // 卡片位次 int64 pos = 7; } // enum FollowType { ft_not_follow = 0; // ft_follow = 1; // } // 动态-附加卡-商品卡-商品 message GoodsItem { // 图片 string cover = 1; // schemaPackageName(Android用) string schema_package_name = 2; // 商品类型 // 1:淘宝 2:会员购 int32 source_type = 3; // 跳转链接 string jump_url = 4; // 跳转文案 string jump_desc = 5; // 标题 string title = 6; // 摘要 string brief = 7; // 价格 string price = 8; // item_id int64 item_id = 9; // schema_url string schema_url = 10; // open_white_list repeated string open_white_list = 11; // use_web_v2 bool user_web_v2 = 12; // ad mark string ad_mark = 13; // string app_name = 14; // GoodsJumpType jump_type = 15; } // enum GoodsJumpType { goods_none = 0; goods_schema = 1; goods_url = 2; } // message GuideBarInfo { // int32 show = 1; // int32 page = 2; // int32 position = 3; // string desc = 4; // int32 jump_page = 5; // int32 jump_position = 6; } // 高亮文本 message HighlightText { // 展示文本 string text = 1; // 高亮类型 HighlightTextStyle text_style = 2; // string jump_url = 3; // string icon = 4; } // 文本高亮枚举 enum HighlightTextStyle { style_none = 0; // 默认 style_highlight = 1; // 高亮 } // enum HomePageTabSttingStatus { SETTING_INVALID = 0; SETTING_OPEN = 1; SETTING_CLOSE = 2; } // message HomeSubscribeReply { // int32 online = 1; } // message HomeSubscribeReq { // int64 campus_id = 1; // string campus_name = 2; } // message IconBadge { // string icon_bg_url = 1; // string text = 2; } // message IconButton { // string text = 1; // string icon_head = 2; // string icon_tail = 3; // string jump_uri = 4; } // enum IconResLocal { ICON_RES_LOCAL_NONE = 0; ICON_RES_LOCAL_LIVE = 1; } // message ImageSet { // string img_day = 1; // string img_dark = 2; } // 枚举-附加卡样式 enum ImageStyle { add_style_vertical = 0; // add_style_square = 1; // } // message ImgInlineCfg { // double width = 1; // double height = 2; // Colors color = 3; } // 动态-拓展小卡模块-ogv小卡-(one of 片单、榜单、分区) message InfoOGV { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // 客户端埋点用 string sub_module = 4; } // message InteractionFace { // int64 mid = 1; // string face = 2; } // 外露交互模块 message InteractionItem { // 外露模块类型 LocalIconType icon_type = 1; // 外露模块文案 repeated Description desc = 2; // 外露模块uri相关 根据type不同用法不同 string uri = 3; // 动态id string dynamic_id = 4; // 评论mid int64 comment_mid = 6; // repeated InteractionFace faces = 7; // InteractionStat stat = 8; // string icon = 9; } // message InteractionStat { // int64 like = 1; } // message LbsPoiDetail { // string poi = 1; // int64 type = 2; // repeated string base_pic = 3; // repeated string cover = 4; // string address = 5; // string title = 6; } // message LbsPoiReply { // bool has_more = 1; // string offset = 2; // LbsPoiDetail detail = 3; // repeated DynamicItem list = 4; } // message LbsPoiReq { // string poi = 1; // int64 type = 2; // string offset = 3; // int32 refresh_type = 4; // int32 local_time = 5; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 6; } // message LegacyTopicFeedReply { // repeated DynamicItem list = 1; // bool has_more = 2; // string offset = 3; // repeated SortType supported_sort_types = 4; // repeated SortType feed_card_filters = 5; } // message LegacyTopicFeedReq { // int64 topic_id = 1; // string topic_name = 2; // string offset = 3; // SortType sort_type = 4; // SortType card_filter = 5; } // enum LightFromType { from_login = 0; // from_unlogin = 1; // } // 点赞动画 message LikeAnimation { // 开始动画 string begin = 1; // 过程动画 string proc = 2; // 结束动画 string end = 3; // id int64 like_icon_id = 4; } // 点赞拓展信息 message LikeInfo { // 点赞动画 LikeAnimation animation = 1; // 是否点赞 bool is_like = 2; } // 点赞列表-响应 message LikeListReply { // 用户模块列表 repeated ModuleAuthor list = 1; // 是否还有更多数据 bool has_more = 2; // 点赞总数 int64 total_count = 3; } // 点赞列表-请求 message LikeListReq { // 动态ID string dynamic_id = 1; // 动态类型 int64 dyn_type = 2; // 业务方资源id int64 rid = 3; //上一页最后一个uid int64 uid_offset = 4; // 下拉页数 int32 page = 5; } // 点赞用户 message LikeUser { // 用户mid int64 uid = 1; // 用户昵称 string uname = 2; // 点击跳转链接 string uri = 3; } // message LineParagraph { // MdlDynDrawItem pic = 1; } // message LinkNode { // WordNode show_text = 1; // string link = 2; // string icon = 3; // string icon_suffix = 4; // string link_type = 5; // LinkNodeType link_type_enum = 6; // string biz_id = 7; // int64 timestamp = 8; // GoodsItem goods_item = 9; // NoteVideoTS note_video_ts = 10; } // enum LinkNodeType { INVALID = 0; VIDEO = 1; RESERVE = 2; VOTE = 3; LIVE = 4; LOTTERY = 5; MATCH = 6; GOODS = 7; OGV_SS = 8; OGV_EP = 9; MANGA = 10; CHEESE = 11; VIDEO_TS = 12; AT = 13; HASH_TAG = 14; ARTICLE = 15; URL = 16; MAIL = 17; LBS = 18; ACTIVITY = 19; ATTACH_CARD_OFFICIAL_ACTIVITY = 20; GAME = 21; DECORATION = 22; UP_TOPIC = 23; UP_ACTIVITY = 24; UP_MAOER = 25; MEMBER_GOODS = 26; OPENMALL_UP_ITEMS = 27; SEARCH = 28; } // 直播信息 message LiveInfo { // 是否在直播 // 0:未直播 1:正在直播 (废弃) int32 is_living = 1; // 跳转链接 string uri = 2; // 直播状态 LiveState live_state = 3; } // message LivePendant { // string text = 1; // string icon = 2; // int64 pendant_id = 3; } // 直播状态 enum LiveState { live_none = 0; // 未直播 live_live = 1; // 直播中 live_rotation = 2; // 轮播中 } // 外露模块类型 enum LocalIconType { local_icon_comment = 0; // local_icon_like = 1; // local_icon_avatar = 2; local_icon_cover = 3; local_icon_like_and_forward = 4; } // 动态-附加卡-电竞卡-战队 message MatchTeam { // 战队ID int64 id = 1; // 战队名 string name = 2; // 战队图标 string cover = 3; // 日间色值 string color = 4; // 夜间色值 string night_color = 5; } // enum MdlBlockedStyle { BLOCKED_STYLE_DEFAULT = 0; BLOCKED_STYLE_IN_AUDIT = 1; BLOCKED_STYLE_ONLY_FANS_LIST = 2; BLOCKED_STYLE_ONLY_FANS_VIDEO = 3; } // 动态列表渲染部分-详情模块-小程序/小游戏 message MdlDynApplet { // 小程序id int64 id = 1; // 跳转地址 string uri = 2; // 主标题 string title = 4; // 副标题 string sub_title = 5; // 封面图 string cover = 6; // 小程序icon string icon = 7; // 小程序标题 string label = 8; // 按钮文案 string button_title = 9; } // 动态-详情模块-稿件 message MdlDynArchive { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // avid int64 avid = 7; // cid int64 cid = 8; // 视频源类型 MediaType media_type = 9; // 尺寸信息 Dimension dimension = 10; // 角标,多个角标之前有间距 repeated VideoBadge badge = 11; // 是否能够自动播放 bool can_play = 12; // stype VideoType stype = 13; // 是否PGC bool isPGC = 14; // inline播放地址 string inlineURL = 15; // PGC的epid int64 EpisodeId = 16; // 子类型 int32 SubType = 17; // PGC的ssid int64 PgcSeasonId = 18; // 播放按钮 string play_icon = 19; // 时长 int64 duration = 20; // 跳转地址 string jump_url = 21; // 番剧是否为预览视频 bool is_preview = 22; // 新角标,多个角标之前没有间距 repeated VideoBadge badge_category = 23; // 当前是否是pgc正片 bool is_feature = 24; // 是否是预约召回 ReserveType reserve_type = 25; // bvid string bvid = 26; // 播放数 int64 view = 27; // bool show_premiere_badge = 28; // bool premiere_card = 29; // bool show_progress = 30; // int64 part_duration = 31; // int64 part_progress = 32; } // 动态列表渲染部分-详情模块-专栏模块 message MdlDynArticle { // 专栏id int64 id = 1; // 跳转地址 string uri = 2; // 标题 string title = 3; // 文案部分 string desc = 4; // 配图 repeated string covers = 5; // 阅读量标签 string label = 6; // 模板类型 int32 templateID = 7; } // message MdlDynChargingArchive { // MdlDynArchive archive_info = 1; // bool has_permission = 2; // bool can_inline = 3; // string charging_bundle_name = 4; // int64 cfg_preview_end_toast_countdown = 5; // int64 cfg_normal_inline_toast_duration = 6; // OneLineText video_bottom_text_upper = 7; // OneLineText video_bottom_text_lower = 8; // string archive_cover = 9; // string archive_title = 10; // IconButton act_btn = 11; // OneLineText text_normal_inline_toast = 12; // OneLineText text_append_preview_end_toast = 13; } // 动态列表渲染部分-详情模块-通用 message MdlDynCommon { // 物料id int64 oid = 1; // 跳转地址 string uri = 2; // 标题 string title = 3; // 描述 漫画卡标题下第一行 string desc = 4; // 封面 string cover = 5; // 标签1 漫画卡标题下第二行 string label = 6; // 所属业务类型 int32 bizType = 7; // 镜像数据ID int64 sketchID = 8; // 卡片样式 MdlDynCommonType style = 9; // 角标 repeated VideoBadge badge = 10; // AdditionalButton button = 11; } // enum MdlDynCommonType { mdl_dyn_common_none = 0; // mdl_dyn_common_square = 1; // mdl_dyn_common_vertica = 2; // } // 动态-详情模块-付费课程批次 message MdlDynCourBatch { // 标题 string title = 1; // 封面图 string cover = 2; // 跳转地址 string uri = 3; // 展示项 1(本集标题) string text_1 = 4; // 展示项 2(更新了多少个视频) string text_2 = 5; // 角标 VideoBadge badge = 6; // 播放按钮 string play_icon = 7; // bool can_play = 8; // bool is_preview = 9; // string cover_left_text_1 = 10; // string cover_left_text_2 = 11; // string cover_left_text_3 = 12; // int64 avid = 13; // int64 cid = 14; // int64 epid = 15; // int64 duration = 16; // int64 season_id = 17; } // 动态-详情模块-付费课程系列 message MdlDynCourSeason { // 标题 string title = 1; // 封面图 string cover = 2; // 跳转地址 string uri = 3; // 展示项 1(更新信息) string text_1 = 4; // 描述信息 string desc = 5; // 角标 VideoBadge badge = 6; // 播放按钮 string play_icon = 7; // bool can_play = 8; // bool is_preview = 9; // int64 avid = 10; // int64 cid = 11; // int64 epid = 12; // int64 duration = 13; // int64 season_id = 14; } // message MdlDynCourUp { // string title = 1; // string desc = 2; // string cover = 3; // string uri = 4; // string text_1 = 5; // VideoBadge badge = 6; // string play_icon = 7; // bool can_play = 8; // bool is_preview = 9; // int64 avid = 10; // int64 cid = 11; // int64 epid = 12; // int64 duration = 13; // int64 season_id = 14; } // 动态列表渲染部分-详情模块-图文模块 message MdlDynDraw { // 图片 repeated MdlDynDrawItem items = 1; // 跳转地址 string uri = 2; // 图文ID int64 id = 3; // bool is_draw_first = 4; // bool is_big_cover = 5; // bool is_article_cover = 6; } // 动态列表渲染部分-详情模块-图文 message MdlDynDrawItem { // 图片链接 string src = 1; // 图片宽度 int64 width = 2; // 图片高度 int64 height = 3; // 图片大小 float size = 4; // 图片标签 repeated MdlDynDrawTag tags = 5; } // 动态列表渲染部分-详情模块-图文-标签 message MdlDynDrawTag { // 标签类型 MdlDynDrawTagType type = 1; // 标签详情 MdlDynDrawTagItem item = 2; } // 动态列表部分-详情模块-图文-标签详情 message MdlDynDrawTagItem { // 跳转链接 string url = 1; // 标签内容 string text = 2; // 坐标-x int64 x = 3; // 坐标-y int64 y = 4; // 方向 int32 orientation = 5; // 来源 // 0:未知 1:淘宝 2:自营 int32 source = 6; // 商品id int64 item_id = 7; // 用户mid int64 mid = 8; // 话题id int64 tid = 9; // lbs信息 string poi = 10; // 商品标签链接 string schema_url = 11; } // 图文标签类型 enum MdlDynDrawTagType { mdl_draw_tag_none = 0; // 占位 mdl_draw_tag_common = 1; // 普通标签 mdl_draw_tag_goods = 2; // 商品标签 mdl_draw_tag_user = 3; // 用户昵称 mdl_draw_tag_topic = 4; // 话题名称 mdl_draw_tag_lbs = 5; // lbs标签 } // 动态列表渲染部分-详情模块-转发模块 message MdlDynForward { // 动态转发核心模块 套娃 DynamicItem item = 1; // 透传类型 // 0:分享 1:转发 int32 rtype = 2; } // 动态列表渲染部分-详情模块-直播 message MdlDynLive { // 房间号 int64 id = 1; // 跳转地址 string uri = 2; // 直播间标题 string title = 3; // 直播间封面 string cover = 4; // 标题1 例: 陪伴学习 string cover_label = 5; // 标题2 例: 54.6万人气 string cover_label2 = 6; // 直播状态 LiveState live_state = 7; // 直播角标 VideoBadge badge = 8; // 是否是预约召回 ReserveType reserve_type = 9; } // 动态列表渲染部分-详情模块-直播推荐 message MdlDynLiveRcmd { // 直播数据 string content = 1; // 是否是预约召回 ReserveType reserve_type = 2; // LivePendant pendant = 3; } // 动态列表渲染部分-详情模块-播单 message MdlDynMedialist { // 播单id int64 id = 1; // 跳转地址 string uri = 2; // 主标题 string title = 3; // 副标题 string sub_title = 4; // 封面图 string cover = 5; // 封面类型 int32 cover_type = 6; // 角标 VideoBadge badge = 7; } // 动态列表渲染部分-详情模块-音频模块 message MdlDynMusic { // 音频id int64 id = 1; // 跳转地址 string uri = 2; // upId int64 up_id = 3; // 歌名 string title = 4; // 专辑封面 string cover = 5; // 展示项1 string label1 = 6; // upper string upper = 7; } // 动态-详情模块-pgc message MdlDynPGC { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // cid int64 cid = 7; // season_id int64 season_id = 8; // epid int64 epid = 9; // aid int64 aid = 10; // 视频源类型 MediaType media_type = 11; // 番剧类型 VideoSubType sub_type = 12; // 番剧是否为预览视频 bool is_preview = 13; // 尺寸信息 Dimension dimension = 14; // 角标,多个角标之前有间距 repeated VideoBadge badge = 15; // 是否能够自动播放 bool can_play = 16; // season PGCSeason season = 17; // 播放按钮 string play_icon = 18; // 时长 int64 duration = 19; // 跳转地址 string jump_url = 20; // 新角标,多个角标之前没有间距 repeated VideoBadge badge_category = 21; // 当前是否是pgc正片 bool is_feature = 22; } // message MdlDynShareChargingQA { // ImageSet background_img = 1; // ImageSet left_icon_img = 2; // string title = 3; // IconButton jump_button = 4; // string uri = 5; // CommonShareCardInfo share_card_meta_info = 6; // string title_prefix_bold = 7; } // 动态列表渲染部分-详情模块-订阅卡 message MdlDynSubscription { // 卡片物料id int64 id = 1; // 广告创意id int64 ad_id = 2; // 跳转地址 string uri = 3; // 标题 string title = 4; // 封面图 string cover = 5; // 广告标题 string ad_title = 6; // 角标 VideoBadge badge = 7; // 小提示 string tips = 8; } // 动态新附加卡 message MdlDynSubscriptionNew { //样式类型 MdlDynSubscriptionNewStyle style = 1; // 新订阅卡数据 oneof item { // MdlDynSubscription dyn_subscription = 2; // 直播推荐 MdlDynLiveRcmd dyn_live_rcmd = 3; } } // enum MdlDynSubscriptionNewStyle { mdl_dyn_subscription_new_style_nont = 0; // 占位 mdl_dyn_subscription_new_style_live = 1; // 直播 mdl_dyn_subscription_new_style_draw = 2; // 图文 } // message MdlDynTopicSet { // repeated TopicItem topics = 1; // IconButton more_btn = 2; // int64 topic_set_id = 3; // int64 push_id = 4; } // 动态列表渲染部分-UGC合集 message MdlDynUGCSeason { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // 卡片物料id int64 id = 7; // inline播放地址 string inlineURL = 8; // 是否能够自动播放 bool can_play = 9; // 播放按钮 string play_icon = 10; // avid int64 avid = 11; // cid int64 cid = 12; // 尺寸信息 Dimension dimension = 13; // 时长 int64 duration = 14; // 跳转地址 string jump_url = 15; } // 播放器类型 enum MediaType { MediaTypeNone = 0; // 本地 MediaTypeUGC = 1; // UGC MediaTypePGC = 2; // PGC MediaTypeLive = 3; // 直播 MediaTypeVCS = 4; // 小视频 } // 查看更多-列表单条数据 message MixUpListItem { // 用户mid int64 uid = 1; // 特别关注 // 0:否 1:是 int32 special_attention = 2; // 小红点状态 // 0:没有 1:有 int32 reddot_state = 3; // 直播信息 MixUpListLiveItem live_info = 4; // 昵称 string name = 5; // 头像 string face = 6; // 认证信息 OfficialVerify official = 7; // 大会员信息 VipInfo vip = 8; // 关注状态 Relation relation = 9; // int32 permire_state = 10; // string uri = 11; } message MixUpListLiveItem { // 直播状态 // 0:未直播 1:直播中 bool status = 1; // 房间号 int64 room_id = 2; // 跳转地址 string uri = 3; } // 动态模块 message Module { // 类型 DynModuleType module_type = 1; oneof module_item { // 用户模块 1 ModuleAuthor module_author = 2; // 争议黄条模块 2 ModuleDispute module_dispute = 3; // 动态正文模块 3 ModuleDesc module_desc = 4; // 动态卡模块 4 ModuleDynamic module_dynamic = 5; // 点赞外露(废弃) ModuleLikeUser module_likeUser = 6; // 小卡模块 6 ModuleExtend module_extend = 7; // 大卡模块 5 ModuleAdditional module_additional = 8; // 计数模块 8 ModuleStat module_stat = 9; // 折叠模块 9 ModuleFold module_fold = 10; // 评论外露(废弃) ModuleComment module_comment = 11; // 外露交互模块(点赞、评论) 7 ModuleInteraction module_interaction = 12; // 转发卡-原卡用户模块 ModuleAuthorForward module_author_forward = 13; // 广告卡 ModuleAd module_ad = 14; // 通栏 ModuleBanner module_banner = 15; // 获取物料失败 ModuleItemNull module_item_null = 16; // 分享组件 ModuleShareInfo module_share_info = 17; // 相关推荐模块 ModuleRecommend module_recommend = 18; // 顶部模块 ModuleTop module_top = 19; // 底部模块 ModuleButtom module_buttom = 20; // 转发卡计数模块 ModuleStat module_stat_forward = 21; // ModuleStory module_story = 22; // ModuleTopic module_topic = 23; // ModuleTopicDetailsExt module_topic_details_ext = 24; // ModuleTopTag module_top_tag = 25; // ModuleTopicBrief module_topic_brief = 26; // ModuleTitle module_title = 27; // ModuleButton module_button = 28; // ModuleNotice module_notice = 29; // ModuleOpusSummary module_opus_summary = 30; // ModuleCopyright module_copyright = 31; // ModuleParagraph module_paragraph = 32; // ModuleBlocked module_blocked = 33; // ModuleTextNotice module_text_notice = 34; // ModuleOpusCollection module_opus_collection = 35; } } // 动态列表-用户模块-广告卡 message ModuleAd { // 广告透传信息 google.protobuf.Any source_content = 1; // 用户模块 ModuleAuthor module_author = 2; // int32 ad_content_type = 3; // string cover_left_text1 = 4; // string cover_left_text2 = 5; // string cover_left_text3 = 6; } // 动态-附加卡模块 message ModuleAdditional { // 类型 AdditionalType type = 1; oneof item { // 废弃 AdditionalPGC pgc = 2; // AdditionGoods goods = 3; // 废弃 AdditionVote vote = 4; // AdditionCommon common = 5; // AdditionEsport esport = 6; // 投票 AdditionVote2 vote2 = 8; // AdditionUgc ugc = 9; // up主预约发布卡 AdditionUP up = 10; // AdditionArticle article = 12; // AdditionLiveRoom live = 13; } // 附加卡物料ID int64 rid = 7; // bool need_write_calender = 11; } // 动态-发布人模块 message ModuleAuthor { // 用户mid int64 mid = 1; // 时间标签 string ptime_label_text = 2; // 用户详情 UserInfo author = 3; // 装扮卡片 DecorateCard decorate_card = 4; // 点击跳转链接 string uri = 5; // 右侧操作区域 - 三点样式 repeated ThreePointItem tp_list = 6; // 右侧操作区域样式枚举 ModuleAuthorBadgeType badge_type = 7; // 右侧操作区域 - 按钮样式 ModuleAuthorBadgeButton badge_button = 8; // 是否关注 // 1:关注 0:不关注 默认0,注:点赞列表使用,其他场景不使用该字段 int32 attend = 9; // 关注状态 Relation relation = 10; // 右侧操作区域 - 提权样式 Weight weight = 11; // 是否展示关注 bool show_follow = 12; // 是否置顶 bool is_top = 13; // ip属地 string ptime_location_text = 14; // bool show_level = 15; // OnlyFans only_fans = 16; } // 动态列表渲染部分-用户模块-按钮 message ModuleAuthorBadgeButton { // 图标 string icon = 1; // 文案 string title = 2; // 状态 int32 state = 3; // 物料ID int64 id = 4; } // 右侧操作区域样式枚举 enum ModuleAuthorBadgeType { module_author_badge_type_none = 0; // 占位 module_author_badge_type_threePoint = 1; // 三点 module_author_badge_type_button = 2; // 按钮类型 module_author_badge_type_weight = 3; // 提权 } // 动态列表-用户模块-转发模板 message ModuleAuthorForward { // 展示标题 repeated ModuleAuthorForwardTitle title = 1; // 源卡片跳转链接 string url = 2; // 用户uid int64 uid = 3; // 时间标签 string ptime_label_text = 4; // 是否展示关注 bool show_follow = 5; // 源up主头像 string face_url = 6; // 双向关系 Relation relation = 7; // 右侧操作区域 - 三点样式 repeated ThreePointItem tp_list = 8; } // 动态列表-用户模块-转发模板-title部分 message ModuleAuthorForwardTitle { // 文案 string text = 1; // 跳转链接 string url = 2; } // 动态列表-通栏 message ModuleBanner { // 模块标题 string title = 1; // 卡片类型 ModuleBannerType type = 2; // 卡片 oneof item{ ModuleBannerUser user = 3; } // 不感兴趣文案 string dislike_text = 4; // 不感兴趣图标 string dislike_icon = 5; } // 动态列表-通栏类型 enum ModuleBannerType { module_banner_type_none = 0; // module_banner_type_user = 1; // } // 动态通栏-用户 message ModuleBannerUser { // 卡片列表 repeated ModuleBannerUserItem list = 1; } // 动态通栏-推荐用户卡 message ModuleBannerUserItem { // up主头像 string face = 1; // up主昵称 string name = 2; // up主uid int64 uid = 3; // 直播状态 LiveState live_state = 4; // 认证信息 OfficialVerify official = 5; // 大会员信息 VipInfo vip = 6; // 标签信息 string label = 7; // 按钮 AdditionalButton button = 8; // 跳转地址 string uri = 9; // Relation relation = 10; } // message ModuleBlocked { // ImageSet icon = 1; // ImageSet bg_img = 2; // string hint_message = 3; // IconButton act_btn = 4; // MdlBlockedStyle block_style = 5; // string sub_hint_message = 6; // OneLineText video_bottom_text_upper = 7; // OneLineText video_bottom_text_lower = 8; // string archive_title = 9; // OneLineText hint_message_one_line = 10; } // 底部模块 message ModuleButtom { enum InteractionIcon { ICON_INVALID = 0; ICON_FORWARD = 1; ICON_COMMENT = 2; ICON_FAVORITE = 3; ICON_LIKE = 4; } // 计数模块 ModuleStat module_stat = 1; // bool comment_box = 2; // string comment_box_msg = 3; // repeated InteractionIcon interaction_icons = 4; // repeated InteractionFace faces = 5; } // message ModuleButton { // IconButton btn = 1; } // 评论外露模块 message ModuleComment { // 评论外露展示项 repeated CmtShowItem cmtShowItem = 1; } // message ModuleCopyright { // string left_text = 1; // string right_text = 2; } // 动态-描述文字模块 message ModuleDesc { // 描述信息(已按高亮拆分) repeated Description desc = 1; // 点击跳转链接 string jump_uri = 2; // 文本原本 string text = 3; } // 正文商品卡参数 message ModuleDescGoods { // 商品类型 // 1:淘宝 2:会员购 int32 source_type = 1; // 跳转链接 string jump_url = 2; // schema_url string schema_url = 3; // item_id int64 item_id = 4; // open_white_list repeated string open_white_list = 5; // use_web_v2 bool user_web_v2 = 6; // ad mark string ad_mark = 7; // schemaPackageName(Android用) string schema_package_name = 8; // int32 jump_type = 9; // string app_name = 10; } // 动态-争议小黄条模块 message ModuleDispute { // 标题 string title = 1; // 描述内容 string desc = 2; // 跳转链接 string uri = 3; } // 动态-详情模块 message ModuleDynamic { // 类型 ModuleDynamicType type = 1; oneof module_item { //稿件 MdlDynArchive dyn_archive = 2; //pgc MdlDynPGC dyn_pgc = 3; //付费课程-系列 MdlDynCourSeason dyn_cour_season = 4; //付费课程-批次 MdlDynCourBatch dyn_cour_batch = 5; //转发卡 MdlDynForward dyn_forward = 6; //图文 MdlDynDraw dyn_draw = 7; //专栏 MdlDynArticle dyn_article = 8; //音频 MdlDynMusic dyn_music = 9; //通用卡方 MdlDynCommon dyn_common = 10; //直播卡 MdlDynLive dyn_common_live = 11; //播单 MdlDynMedialist dyn_medialist = 12; //小程序卡 MdlDynApplet dyn_applet = 13; //订阅卡 MdlDynSubscription dyn_subscription = 14; //直播推荐卡 MdlDynLiveRcmd dyn_live_rcmd = 15; //UGC合集 MdlDynUGCSeason dyn_ugc_season = 16; //订阅卡 MdlDynSubscriptionNew dyn_subscription_new = 17; //课程 MdlDynCourUp dyn_cour_batch_up = 18; //话题集合 MdlDynTopicSet dyn_topic_set = 19; //充电稿件 MdlDynChargingArchive dyn_charging_archive = 20; // MdlDynShareChargingQA dyn_share_charging_qa = 21; } } // 动态详情模块类型 enum ModuleDynamicType { mdl_dyn_archive = 0; // 稿件 mdl_dyn_pgc = 1; // pgc mdl_dyn_cour_season = 2; // 付费课程-系列 mdl_dyn_cour_batch = 3; // 付费课程-批次 mdl_dyn_forward = 4; // 转发卡 mdl_dyn_draw = 5; // 图文 mdl_dyn_article = 6; // 专栏 mdl_dyn_music = 7; // 音频 mdl_dyn_common = 8; // 通用卡方 mdl_dyn_live = 9; // 直播卡 mdl_dyn_medialist = 10; // 播单 mdl_dyn_applet = 11; // 小程序卡 mdl_dyn_subscription = 12; // 订阅卡 mdl_dyn_live_rcmd = 13; // 直播推荐卡 mdl_dyn_ugc_season = 14; // UGC合集 mdl_dyn_subscription_new = 15; // 订阅卡 mdl_dyn_cour_batch_up = 16; // 课程 mdl_dyn_topic_set = 17; // 话题集合 } // 动态-小卡模块 message ModuleExtend { // 详情 repeated ModuleExtendItem extend = 1; // 模块整体跳转uri string uri = 2; // 废弃 } // 动态-拓展小卡模块 message ModuleExtendItem { // 类型 DynExtendType type = 1; // 卡片详情 oneof extend { // 废弃 ExtInfoTopic ext_info_topic = 2; // 废弃 ExtInfoLBS ext_info_lbs = 3; // 废弃 ExtInfoHot ext_info_hot = 4; // 废弃 ExtInfoGame ext_info_game = 5; // ExtInfoCommon ext_info_common = 6; // ExtInfoOGV ext_info_ogv = 7; } } // 动态-折叠模块 message ModuleFold { // 折叠分类 FoldType fold_type = 1; // 折叠文案 string text = 2; // 被折叠的动态 string fold_ids = 3; // 被折叠的用户信息 repeated UserInfo fold_users = 4; // TopicMergedResource topic_merged_resource = 5; } // 外露交互模块 message ModuleInteraction { // 外露交互模块 repeated InteractionItem interaction_item = 1; } // 获取物料失败模块 message ModuleItemNull { // 图标 string icon = 1; // 文案 string text = 2; } // 动态-点赞用户模块 message ModuleLikeUser { // 点赞用户 repeated LikeUser like_users = 1; // 文案 string display_text = 2; } // message ModuleNotice { // string identity = 1; // string icon = 2; // string title = 3; // string url = 4; // int32 notice_type = 5; } // message ModuleOpusCollection { // OpusCollection collection_info = 1; // string title_upper = 2; // string title = 3; // string title_prefix_icon = 4; // string total_text = 5; } // message ModuleOpusSummary { // Paragraph title = 1; // Paragraph summary = 2; // string summary_jump_btn_text = 3; // repeated MdlDynDrawItem covers = 4; } // message ModuleParagraph { // Paragraph paragraph = 1; // bool is_article_title = 2; // ParaSpacing para_spacing = 3; } // 推荐模块 message ModuleRcmd { // 用户头像 RcmdAuthor author = 1; // 推荐卡片列表 repeated RcmdItem items = 2; // 透传到客户端的埋点字段 string server_info = 3; } // 相关推荐模块 message ModuleRecommend { // 模块标题 string module_title = 1; // 图片 string image = 2; // 标签 string tag = 3; // 标题 string title = 4; // 跳转链接 string jump_url = 5; // 序列化的广告信息 repeated google.protobuf.Any ad = 6; } // 分享模块 message ModuleShareInfo { // 展示标题 string title = 1; // 分享组件列表 repeated ShareChannel share_channels = 2; // share_origin string share_origin = 3; // 业务id string oid = 4; // sid string sid = 5; } // 动态-计数模块 message ModuleStat { // 转发数 int64 repost = 1; // 点赞数 int64 like = 2; // 评论数 int64 reply = 3; // 点赞拓展信息 LikeInfo like_info = 4; // 禁评 bool no_comment = 5; // 禁转 bool no_forward = 6; // 点击评论跳转链接 string reply_url = 7; // 禁评文案 string no_comment_text = 8; // 禁转文案 string no_forward_text = 9; } // message ModuleStory { // string title = 1; // repeated StoryItem items = 2; // bool show_publish_entrance = 3; // int64 fold_state = 4; // string uri = 5; // string cover = 6; // string publish_text = 7; } // message ModuleTextNotice { // OneLineText notice = 1; } // message ModuleTitle { // string title = 1; // IconButton right_btn = 2; // int32 title_style = 3; } // 顶部模块 message ModuleTop { // 三点模块 repeated ThreePointItem tp_list = 1; // MdlDynArchive archive = 2; // ModuleAuthor author = 3; // bool hidden_nav_bar = 4; } // message ModuleTopic { // int64 id = 1; // string name = 2; // string url = 3; } // message ModuleTopicBrief { // TopicItem topic = 1; } // message ModuleTopicDetailsExt { // string comment_guide = 1; } // message ModuleTopTag { // string tag_name = 1; } // 认证名牌 message Nameplate { // nid int64 nid = 1; // 名称 string name = 2; // 图片地址 string image = 3; // 小图地址 string image_small = 4; // 等级 string level = 5; // 获取条件 string condition = 6; } enum NetworkType { NT_UNKNOWN = 0; // WIFI = 1; // CELLULAR = 2; // OFFLINE = 3; // OTHERNET = 4; // ETHERNET = 5; // } // 最新ep message NewEP { // 最新话epid int32 id = 1; // 更新至XX话 string index_show = 2; // 更新剧集的封面 string cover = 3; } // message NFTInfo { // NFTRegionType region_type = 1; // string region_icon = 2; // NFTShowStatus region_show_status = 3; } // enum NFTRegionType { nft_region_default = 0; nft_region_mainlang = 1; nft_region_gat = 2; } // enum NFTShowStatus { nft_show_default = 0; nft_show_zoominmainlang = 1; nft_show_raw = 2; } // 空响应 message NoReply { } // 空请求 message NoReq { } // message NoteVideoTS { // int64 cid = 1; // int64 oid_type = 2; // int64 status = 3; // int64 index = 4; // int64 seconds = 5; // int64 cid_count = 6; // string key = 7; // int64 epid = 9; // string title = 8; // string desc = 10; } // message OfficialAccountInfo { // UserInfo author = 1; // int64 mid = 2; // string uri = 3; // Relation relation = 4; // string desc_text1 = 5; // string desc_text2 = 6; } // message OfficialAccountsReply { // repeated OfficialAccountInfo items = 1; // bool has_more = 2; // int64 offset = 3; } // message OfficialAccountsReq { // int64 campus_id = 1; // string campus_name = 2; // int64 offset = 3; } // message OfficialDynamicsReply { // repeated OfficialItem items = 1; // int64 offset = 2; // bool has_more = 3; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; } // message OfficialDynamicsReq { // int64 campus_id = 1; // string campus_name = 2; // int64 offset = 3; } message OfficialItem { int32 type = 1; oneof rcmd_item { OfficialRcmdArchive rcmd_archive = 2; OfficialRcmdDynamic rcmd_dynamic = 3; }; } // message OfficialRcmdArchive { // string title = 1; // string cover = 2; // string cover_right_text = 3; // int32 desc_icon1 = 4; // string desc_text1 = 5; // int32 desc_icon2 = 6; // string desc_text2 = 7; // string reason = 8; // bool show_three_point = 9; // string uri = 10; // int64 aid = 11; // int64 mid = 12; // string name = 13; // int64 dynamic_id = 14; // int64 cid = 15; } // message OfficialRcmdDynamic { // string title = 1; // string cover = 2; // string cover_right_top_text = 3; // int32 desc_icon1 = 4; // string desc_text1 = 5; // int32 desc_icon2 = 6; // string desc_text2 = 7; // string reason = 8; // string uri = 9; // int64 dynamic_id = 10; // int64 mid = 11; // string user_name = 12; // int64 rid = 13; } // 认证信息 message OfficialVerify { // 127:未认证 0:个人 1:机构 int32 type = 1; // 认证描述 string desc = 2; // 是否关注 int32 is_atten = 3; } // message OneLineText { // repeated TextWithPriority texts = 1; } // message OnlyFans { // bool is_only_fans = 1; // IconBadge badge = 2; } // message OnlyFansProperty { // bool has_privilege = 1; // bool is_only_fans = 2; } // message OpusCollection { // int64 collection_id = 1; // OneLineText title = 2; // string detail_uri = 3; // string intro = 4; // repeated OpusCollectionItem all_items = 5; } // message OpusCollectionItem { // int64 opus_id = 1; // string title = 2; // string pub_time = 3; // string uri = 4; // bool is_selected_highlight = 5; } // message Paragraph { // message ListFormat { // int32 level = 1; // int32 order = 2; // string theme = 3; } enum ParagraphAlign { LEFT = 0; MIDDLE = 1; RIGHT = 2; } // message ParagraphFormat { // ParagraphAlign align = 1; // ListFormat list_format = 2; } // enum ParagraphType { INVALID = 0; TEXT = 1; PICTURES = 2; LINE = 3; REFERENCE = 4; SORTED_LIST = 5; UNSORTED_LIST = 6; LINK_CARD = 7; } // ParagraphType para_type = 1; // ParagraphFormat para_format = 2; // oneof content { TextParagraph text = 3; PicParagraph pic = 4; LineParagraph line = 5; CardParagraph link_card = 6; } } // message ParaSpacing { // double spacing_before_para = 1; // double spacing_after_para = 2; // double line_spacing = 3; } // PGC单季信息 message PGCSeason { // 是否完结 int32 is_finish = 1; // 标题 string title = 2; // 类型 int32 type = 3; } // message PicParagraph { // enum PicParagraphStyle { INVALID = 0; NINE_CELL = 1; BIG_SCROLL = 2; } // MdlDynDraw pics = 1; // PicParagraphStyle style = 2; } // 秒开通用参数 message PlayurlParam { // 清晰度 int32 qn = 1; // 流版本 int32 fnver = 2; // 流类型 int32 fnval = 3; // 是否强制使用域名 int32 force_host = 4; // 是否4k int32 fourk = 5; } // message Popup { // string title = 1; // string desc = 2; // string uri = 3; } // message RcmdArchive { // 标题 string title = 1; // 封面图 string cover = 2; // 视频封面展示项 1 CoverIcon cover_left_icon_1 = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 秒开地址 string uri = 5; // 是否PGC bool is_pgc = 6; // aid int64 aid = 7; // IconBadge badge = 8; // int32 cover_left_icon2 = 9; // string cover_left_text2 = 10; // int32 cover_left_icon3 = 11; // string cover_left_text3 = 12; } // 推荐UP主用户模块 message RcmdAuthor { // 用户详情 UserInfo author = 1; // 描述:粉丝数、推荐理由 string desc = 2; // 关注状态 Relation relation = 3; } // message RcmdCampusBrief { // int64 campus_id = 1; // string campus_name = 2; // string campus_badge = 4; // string url = 5; } // 推荐卡片列表 message RcmdItem { // 卡片类型 RcmdType type = 1; // 卡片列表 oneof rcmd_item { // RcmdArchive rcmd_archive = 2; } } // 分区聚类推荐选项 message RcmdOption{ // 视频是否展示标题 bool show_title = 1; } // message RcmdReason { // string campus_name = 1; // RcmdReasonStyle style = 2; // string rcmd_reason = 3; // string up_name = 4; } // enum RcmdReasonStyle { rcmd_reason_style_none = 0; rcmd_reason_style_campus_nearby = 1; rcmd_reason_style_campus_up = 2; rcmd_reason_style_campus_near_up_mix = 3; } // message RcmdTopButton { // string text = 1; // string url = 2; } // 推荐模块数据类型 enum RcmdType { rcmd_archive = 0; // 稿件 rcmd_dynamic = 1; // 动态 } // 推荐up主入参 message RcmdUPsParam { int64 dislike_ts = 1; } message ReactionListItem { // 用户信息 UserInfo user = 1; // 关注关系 Relation relation = 2; // 显示文字 string act_text = 3; // string rcmd_reason = 4; } // 新版动态转发点赞列表-响应 message ReactionListReply { // 标题 string title = 1; // 列表 repeated ReactionListItem list = 2; // 偏移 string offset = 3; // 是否还有更多 bool has_more = 4; } // 新版动态转发点赞列表-请求 message ReactionListReq { // 动态ID int64 dynamic_id = 1; // 动态类型 int64 dyn_type = 2; // 业务方资源id int64 rid = 3; // 偏移,使用上一页回包中的offset字段;第一页不传。 string offset = 4; } // 刷新方式 enum Refresh { refresh_new = 0; // 刷新列表 refresh_history = 1; // 请求历史 } // 关注关系 message Relation { // 关注状态 RelationStatus status = 1; // 关注 int32 is_follow = 2; // 被关注 int32 is_followed = 3; // 文案 string title = 4; } // 关注关系 枚举 enum RelationStatus { // 1-未关注 2-关注 3-被关注 4-互相关注 5-特别关注 relation_status_none = 0; relation_status_nofollow = 1; relation_status_follow = 2; relation_status_followed = 3; relation_status_mutual_concern = 4; relation_status_special = 5; } // 转发列表-请求 message RepostListReq { // 动态ID string dynamic_id = 1; // 动态类型 int64 dyn_type = 2; // 业务方资源id int64 rid = 3; // 偏移,使用上一页回包中的offset字段;第一页不传。 string offset = 4; // 来源 string from = 5; // 评论类型 RepostType repost_type = 6; } // 转发列表-响应 message RepostListRsp { // 列表 repeated DynamicItem list = 1; // 偏移 string offset = 2; // 是否还有更多 bool has_more = 3; // 转发总数 int64 total_count = 4; // 评论类型 RepostType repost_type = 5; } // 评论类型 enum RepostType { repost_hot = 0; // 热门评论 repost_general = 1; // 普通评论 } // enum ReserveRelationLotteryType { reserve_relation_lottery_type_default = 0; // reserve_relation_lottery_type_cron = 1; // } // enum ReserveType { reserve_none = 0; // 占位 reserve_recall = 1; // 预约召回 } enum RouterAction { OPEN = 0; EMBED = 1; } // message SchoolRecommendReply { // repeated CampusInfo items = 1; } // message SchoolRecommendReq { // float lat = 1; // float lng = 2; // CampusReqFromType from_type = 3; } // message SchoolSearchReply { // repeated CampusInfo items = 1; // SearchToast toast = 2; } // message SchoolSearchReq { // string keyword = 1; // CampusReqFromType from_type = 2; } // message SearchChannel { // string title = 1; // SearchTopicButton more_button = 2; // repeated ChannelInfo channels = 3; } // message SearchInfo { // string title = 1; // repeated DynamicItem list = 2; // string track_id = 3; // int64 total = 4; // bool has_more = 5; // string version = 6; } // message SearchToast { // string desc_text1 = 1; // string desc_text2 = 2; } // message SearchTopic { // string title = 1; // SearchTopicButton more_button = 2; // repeated SearchTopicItem items = 3; } // message SearchTopicButton { // string title = 1; // string jump_uri = 2; } // message SearchTopicItem { // int64 topic_id = 1; // string topic_name = 2; // string desc = 3; // string url = 4; // bool is_activity = 5; } // message SetDecisionReq { // int32 result = 1; // CampusReqFromType from_type = 2; } // message SetRecentCampusReq { // int64 campus_id = 1; // string campus_name = 2; // CampusReqFromType from_type = 3; } // 分享渠道组件 message ShareChannel { // 分享名称 string name = 1; // 分享按钮图片 string image = 2; // 分享渠道 string channel = 3; // 预约卡分享图信息,仅分享有预约信息的动态时存在 ShareReserve reserve = 4; } // 预约卡分享图信息 message ShareReserve { // 展示标题 string title = 1; // 描述(时间+类型) string desc = 2; // 二维码附带icon string qr_code_icon = 3; // 二维码附带文本 string qr_code_text = 4; // 二维码url string qr_code_url = 5; // AdditionUserInfo user_info = 6; } // enum ShowType { show_type_none = 0; // show_type_backup = 1; // } // 排序类型 message SortType { // 排序策略 // 1:推荐排序 2:最常访问 3:最近关注 int32 sort_type = 1; // 排序策略名称 string sort_type_name = 2; } // message StoryArchive { // string cover = 1; // int64 aid = 2; // string uri = 3; // Dimension dimension = 4; } // message StoryItem { // UserInfo author = 1; // string desc = 2; // int64 status = 3; // int32 type = 4; oneof rcmd_item { // StoryArchive story_archive = 5; } } // enum StyleType { STYLE_TYPE_NONE = 0; // STYLE_TYPE_LIVE = 1; // STYLE_TYPE_DYN_UP = 2; // } // message SubscribeCampusReq { // int64 campus_id = 1; // string campus_name = 2; // CampusReqFromType from_type = 3; } // message TextNode { enum TextNodeType { INVALID = 0; WORDS = 1; EMOTE = 2; AT = 3; BIZ_LINK = 4; } // TextNodeType node_type = 1; string raw_text = 2; // oneof text { WordNode word = 3; EmoteNode emote = 4; LinkNode link = 5; } } // message TextParagraph { // repeated TextNode nodes = 1; } // message TextWithPriority { // string text = 1; // int64 priority = 2; } // 免流类型 enum TFType { TF_UNKNOWN = 0; // 未知 U_CARD = 1; // 联通卡 U_PKG = 2; // 联通免流包 C_CARD = 3; // 移动卡 C_PKG = 4; // 移动免流包 T_CARD = 5; // 电信卡 T_PKG = 6; // 电信免流包 } // 三点-关注 message ThreePointAttention { // attention icon string attention_icon = 1; // 关注时显示的文案 string attention_text = 2; // not attention icon string not_attention_icon = 3; // 未关注时显示的文案 string not_attention_text = 4; // 当前关注状态 ThreePointAttentionStatus status = 5; } // 枚举-三点关注状态 enum ThreePointAttentionStatus { tp_not_attention = 0; // tp_attention = 1; // } // 三点-自动播放 旧版不维护 message ThreePointAutoPlay { // open icon string open_icon = 1; // 开启时显示文案 string open_text = 2; // close icon string close_icon = 3; // 关闭时显示文案 string close_text = 4; // 开启时显示文案v2 string open_text_v2 = 5; // 关闭时显示文案v2 string close_text_v2 = 6; // 仅wifi/免流 icon string only_icon = 7; // 仅wifi/免流 文案 string only_text = 8; // open icon v2 string open_icon_v2 = 9; // close icon v2 string close_icon_v2 = 10; } // 三点-评论 message ThreePointComment { // 精选评论区功能 CommentDetail up_selection = 1; // up关闭评论区功能 CommentDetail up_close = 2; // icon string icon = 3; // 标题 string title = 4; } // 三点-默认结构(使用此背景、举报、删除) message ThreePointDefault { // icon string icon = 1; // 标题 string title = 2; // 跳转链接 string uri = 3; // id string id = 4; // ThreePointDefaultToast toast = 5; } // message ThreePointDefaultToast { // string title = 1; // string desc = 2; } // 三点-不感兴趣 message ThreePointDislike { // icon string icon = 1; // 标题 string title = 2; } // 三点-收藏 message ThreePointFavorite { // icon string icon = 1; // 标题 string title = 2; // 物料ID int64 id = 3; // 是否订阅 bool is_favourite = 4; // 取消收藏图标 string cancel_icon = 5; // 取消收藏文案 string cancel_title = 6; } // message ThreePointHide { // string icon = 1; // string title = 2; // ThreePointHideInteractive interactive = 3; // int64 blook_fid = 4; // string blook_type = 5; } // message ThreePointHideInteractive { // string title = 1; // string confirm = 2; // string cancel = 3; // string toast = 4; } // 三点Item message ThreePointItem { //类型 ThreePointType type = 1; oneof item { // 默认结构 ThreePointDefault default = 2; // 自动播放 ThreePointAutoPlay auto_player = 3; // 分享 ThreePointShare share = 4; // 关注 ThreePointAttention attention = 5; // 稍后在看 ThreePointWait wait = 6; // 不感兴趣 ThreePointDislike dislike = 7; // 收藏 ThreePointFavorite favorite = 8; // 置顶 ThreePointTop top = 9; // 评论 ThreePointComment comment = 10; // ThreePointHide hide = 11; // ThreePointTopicIrrelevant topic_irrelevant = 12; } } // 三点-分享 message ThreePointShare { // icon string icon = 1; // 标题 string title = 2; // 分享渠道 repeated ThreePointShareChannel channel = 3; // 分享渠道名 string channel_name = 4; // 预约卡分享图信息,仅分享有预约信息的动态时存在 ShareReserve reserve = 5; } // 三点-分享渠道 message ThreePointShareChannel { // icon string icon = 1; // 名称 string title = 2; } // 三点-置顶 message ThreePointTop { // icon string icon = 1; // 标题 string title = 2; // 状态 TopType type = 3; } // message ThreePointTopicIrrelevant { // string icon = 1; // string title = 2; // string toast = 3; // int64 topic_id = 4; // int64 res_id = 5; // int64 res_type = 6; // string reason = 7; } // 三点类型 enum ThreePointType { tp_none = 0; // 占位 background = 1; // 使用此背景 auto_play = 2; // 自动播放 share = 3; // 分享 wait = 4; // 稍后再播 attention = 5; // 关注 report = 6; // 举报 delete = 7; // 删除 dislike = 8; // 不感兴趣 favorite = 9; // 收藏 top = 10; // 置顶 comment = 11; // 评论 hide = 12; // campus_delete = 13; // topic_irrelevant = 14; // } // 三点-稍后在看 message ThreePointWait { // addition icon string addition_icon = 1; // 已添加时的文案 string addition_text = 2; // no addition icon string no_addition_icon = 3; // 未添加时的文案 string no_addition_text = 4; // avid int64 id = 5; } // enum ThumbType { cancel = 0; // thumb = 1; // } // 顶部预约卡 message TopAdditionUP { // 预约卡 repeated AdditionUP up = 1; // 折叠数量,大于多少个进行折叠 int32 has_fold = 2; } // 话题广场操作按钮 message TopicButton { // 按钮图标 string icon = 1; // 按钮文案 string title = 2; // 跳转 string jump_uri = 3; // bool red_dot = 4; } // message TopicItem { // int64 topic_id = 1; // string topic_name = 2; // string url = 3; // string desc = 4; // string desc2 = 5; // string rcmd_desc = 6; } // 综合页-话题广场 message TopicList { // 模块标题 string title = 1; // 话题列表 repeated TopicListItem topic_list_item = 2; // 发起活动 TopicButton act_button = 3; // 查看更多 TopicButton more_button = 4; // 透传服务端上报 string server_info = 5; } // 综合页-话题广场-话题 message TopicListItem { // 前置图标 string icon = 1; // 前置图标文案 string icon_title = 2; // 话题id int64 topic_id = 3; // 话题名 string topic_name = 4; // 跳转链接 string url = 5; // 卡片位次 int64 pos = 6; // 透传服务端上报 string server_info = 7; // string head_icon_url = 8; // int64 up_mid = 9; // string tail_icon_url = 10; // string extension = 11; } // message TopicListReply { // repeated TopicItem items = 1; // bool has_more = 2; // string offset = 3; } // message TopicListReq { // int64 campus_id = 1; // string offset = 2; } // message TopicMergedResource { // int32 merge_type = 1; // int32 merged_res_cnt = 2; } // message TopicRcmdCard { // int64 topic_id = 1; // string topic_name = 2; // string url = 3; // CampusLabel button = 4; // string desc1 = 5; // string desc2 = 6; // string update_desc = 7; } // message TopicSquareInfo { // string title = 1; // CampusLabel button = 2; // TopicRcmdCard rcmd = 3; } // message TopicSquareReply { // TopicSquareInfo info = 1; } // message TopicSquareReq { // int64 campus_id = 1; } // 状态 enum TopType { top_none = 0; // 默认 置顶 top_cancel = 1; // 取消置顶 } // 综合页-无关注列表 message Unfollow { // 标题展示文案 string title = 1; // 无关注列表 repeated UnfollowUserItem list = 2; // trackID string TrackId = 3; } // message UnfollowMatchReq { // int64 cid = 1; } // 综合页-无关注列表 message UnfollowUserItem { // 是否有更新 bool has_update = 1; // up主头像 string face = 2; // up主昵称 string name = 3; // up主uid int64 uid = 4; // 排序字段 从1开始 int32 pos = 5; // 直播状态 LiveState live_state = 6; // 认证信息 OfficialVerify official = 7; // 大会员信息 VipInfo vip = 8; // up介绍 string sign = 9; // 标签信息 string label = 10; // 按钮 AdditionalButton button = 11; // 跳转地址 string uri = 12; } // message UpdateTabSettingReq { // HomePageTabSttingStatus status = 1; } // 动态顶部up列表-up主信息 message UpListItem { // 是否有更新 bool has_update = 1; // up主头像 string face = 2; // up主昵称 string name = 3; // up主uid int64 uid = 4; // 排序字段 从1开始 int64 pos = 5; // 用户类型 UserItemType user_item_type = 6; // 直播头像样式-日 UserItemStyle display_style_day = 7; // 直播头像样式-夜 UserItemStyle display_style_night = 8; // 直播埋点 int64 style_id = 9; // 直播状态 LiveState live_state = 10; // 分割线 bool separator = 11; // 跳转 string uri = 12; // UP主预约上报使用 bool is_recall = 13; // IconBadge update_icon = 14; // string live_rcmd_reason = 15; // string live_cover = 16; // string personal_extra = 17; } // 最常访问-查看更多 message UpListMoreLabel { // 文案 string title = 1; // 跳转地址 string uri = 2; } // 用户信息 message UserInfo { // 用户mid int64 mid = 1; // 用户昵称 string name = 2; // 用户头像 string face = 3; // 认证信息 OfficialVerify official = 4; // 大会员信息 VipInfo vip = 5; // 直播信息 LiveInfo live = 6; // 空间页跳转链接 string uri = 7; // 挂件信息 UserPendant pendant = 8; // 认证名牌 Nameplate nameplate = 9; // 用户等级 int32 level = 10; // 用户简介 string sign = 11; // int32 face_nft = 12; // int32 face_nft_new = 13; // NFTInfo nft_info = 14; // int32 is_senior_member = 15; // bilibili.dagw.component.avatar.v1.AvatarItem avatar = 16; } // 直播头像样式 message UserItemStyle { // string rect_text = 1; // string rect_text_color = 2; // string rect_icon = 3; // string rect_bg_color = 4; // string outer_animation = 5; } // 用户类型 enum UserItemType { user_item_type_none = 0; // user_item_type_live = 1; // user_item_type_live_custom = 2; // user_item_type_normal = 3; // user_item_type_extend = 4; // user_item_type_premiere_reserve = 5; user_item_type_premiere = 6; user_item_type_live_card = 7; user_item_type_ogv_season = 8; user_item_type_ugc_season = 9; } // 头像挂件信息 message UserPendant { // pid int64 pid = 1; // 名称 string name = 2; // 图片链接 string image = 3; // 有效期 int64 expire = 4; } // 角标信息 message VideoBadge { // 文案 string text = 1; // 文案颜色-日间 string text_color = 2; // 文案颜色-夜间 string text_color_night = 3; // 背景颜色-日间 string bg_color = 4; // 背景颜色-夜间 string bg_color_night = 5; // 边框颜色-日间 string border_color = 6; // 边框颜色-夜间 string border_color_night = 7; // 样式 int32 bg_style = 8; // 背景透明度-日间 int32 bg_alpha = 9; // 背景透明度-夜间 int32 bg_alpha_night = 10; } // 番剧类型 enum VideoSubType { VideoSubTypeNone = 0; // 没有子类型 VideoSubTypeBangumi = 1; // 番剧 VideoSubTypeMovie = 2; // 电影 VideoSubTypeDocumentary = 3; // 纪录片 VideoSubTypeDomestic = 4; // 国创 VideoSubTypeTeleplay = 5; // 电视剧 } // 视频类型 enum VideoType { video_type_general = 0; //普通视频 video_type_dynamic = 1; //动态视频 video_type_playback = 2; //直播回放视频 video_type_story = 3; // } // 大会员信息 message VipInfo { // 大会员类型 int32 Type = 1; // 大会员状态 int32 status = 2; // 到期时间 int64 due_date = 3; // 标签 VipLabel label = 4; // 主题 int32 theme_type = 5; // 大会员角标 // 0:无角标 1:粉色大会员角标 2:绿色小会员角标 int32 avatar_subscript = 6; // 昵称色值,可能为空,色值示例:#FFFB9E60 string nickname_color = 7; } // 大会员标签 message VipLabel { // 图片地址 string path = 1; // 文本值 string text = 2; // 对应颜色类型 string label_theme = 3; } // 状态 enum VoteStatus { normal = 0; // 正常 anonymous = 1; // 匿名 } // 提权样式 message Weight { // 提权展示标题 string title = 1; // 下拉框内容 repeated WeightItem items = 2; // icon string icon = 3; } // 热门默认跳转按钮 message WeightButton { string jump_url = 1; // 展示文案 string title = 2; } // 提权不感兴趣 message WeightDislike { // 负反馈业务类型 作为客户端调用负反馈接口的参数 string feed_back_type = 1; // 展示文案 string title = 2; } // 提权样式 message WeightItem { // 类型 WeightType type = 1; oneof item { // 热门默认跳转按钮 WeightButton button = 2; // 提权不感兴趣 WeightDislike dislike = 3; } } // 枚举-提权类型 enum WeightType { weight_none = 0; // 默认 占位 weight_dislike = 1; // 不感兴趣 weight_jump = 2; // 跳链 } // enum WFItemType { WATER_FLOW_TYPE_NONE = 0; WATER_FLOW_TYPE_ARCHIVE = 1; WATER_FLOW_TYPE_DYNAMIC = 2; } // message WordNode { // message WordNodeStyle { // bool bold = 1; // bool italic = 2; // bool strikethrough = 3; // bool underline = 4; } // string words = 1; // double font_size = 2; // Colors color = 3; // WordNodeStyle style = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/dynamic/v2/opus.proto ================================================ syntax = "proto3"; package bilibili.app.dynamic.v2; option java_multiple_files = true; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/app/dynamic/v2/dynamic.proto"; service Opus { // rpc OpusDetail (OpusDetailReq) returns (OpusDetailResp); } // message OpusDetailReq { // OpusType opus_type = 1; // int64 oid = 2; // int64 dyn_type = 3; // string share_id = 4; // int32 share_mode = 9; // int32 local_time = 10; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 11; // Config config = 12; } // message OpusDetailResp { // OpusItem opus_item = 1; } // message OpusItem { // int64 opus_id = 1; // OpusType opus_type = 2; // int64 oid = 3; // repeated Module modules = 4; // Extend extend = 5; } enum OpusType { OPUS_TYPE_DYN = 0; OPUS_TYPE_ARTICLE = 1; OPUS_TYPE_NOTE = 2; OPUS_TYPE_WORD = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/interfaces/v1/history.proto ================================================ syntax = "proto3"; package bilibili.app.interface.v1; option java_package = "bilibili.app.interfaces.v1"; option java_multiple_files = true; import "bilibili/app/archive/middleware/v1/preload.proto"; // 历史记录 service History { // 获取历史记录tab rpc HistoryTab (HistoryTabReq) returns (HistoryTabReply); // 获取历史记录列表(旧版) rpc Cursor (CursorReq) returns (CursorReply); // 获取历史记录列表 rpc CursorV2 (CursorV2Req) returns (CursorV2Reply); // 删除历史记录 rpc Delete (DeleteReq) returns (NoReply); // 搜索历史记录 rpc Search (SearchReq) returns (SearchReply); // 清空历史记录 rpc Clear (ClearReq) returns (NoReply); // 获取最新的历史记录 rpc LatestHistory (LatestHistoryReq) returns (LatestHistoryReply); } // 专栏卡片 message CardArticle { // 封面url repeated string covers = 1; // UP主昵称 string name = 2; // UP主mid int64 mid = 3; // 是否展示关注按钮 bool displayAttention = 4; // 角标 string badge = 5; // 关系信息 Relation relation = 6; } // 课程卡片 message CardCheese { // 封面url string cover = 1; // 观看进度 int64 progress = 2; // 总计时长 int64 duration = 3; // 单集标题 string subtitle = 4; // int64 state = 5; } // 直播卡片 message CardLive { // 封面url string cover = 1; // 主播昵称 string name = 2; // 主播mid int64 mid = 3; // 直播分区名 string tag = 4; // 直播状态 int32 ststus = 5; // 是否展示关注按钮 bool display_attention = 6; // 关系信息 Relation relation = 7; } // pgc稿件卡片 message CardOGV { // 封面url string cover = 1; // 观看进度 int64 progress = 2; // 总计时长 int64 duration = 3; // 单集标题 string subtitle = 4; // string badge = 5; // int64 state = 6; } // ugc稿件卡片 message CardUGC { // 封面url string cover = 1; // 观看进度 int64 progress = 2; // 视频长度 int64 duration = 3; // UP主昵称 string name = 4; // UP主mid int64 mid = 5; // 是否展示关注按钮 bool display_attention = 6; // 历史观看视频cid int64 cid = 7; // 历史观看视频分P int32 page = 8; // 历史观看视频分P的标题 string subtitle = 9; // 关系信息 Relation relation = 10; // 稿件bvid string bvid = 11; // 总分P数 int64 videos = 12; // 短链接 string short_link = 13; // 分享副标题 string share_subtitle = 14; // 播放数 int64 view = 15; // int64 state = 16; } // 清空历史记录-请求 message ClearReq { // 业务类型 // archive:视频 live:直播 article:专栏 goods:商品 show:展演 string business = 1; } // 游标信息 message Cursor { // 本页最大值游标值 int64 max = 1; // 本页最大值游标类型 int32 maxTp = 2; } // 历史记录卡片信息 message CursorItem { // 主体数据 oneof card_item { // ugc稿件 CardUGC card_ugc = 1; // pgc稿件 CardOGV card_ogv = 2; // 专栏 CardArticle card_article = 3; // 直播 CardLive card_live = 4; // 课程 CardCheese card_cheese = 5; } // 标题 string title = 6; // 目标uri/url string uri = 7; // 观看时间 int64 viewAt = 8; // 历史记录id int64 kid = 9; // 业务id int64 oid = 10; // 业务类型 // archive:视频 live:直播 article:专栏 goods:商品 show:展演 string business = 11; // 业务类型代码 int32 tp = 12; // 设备标识 DeviceType dt = 13; // 是否有分享按钮 bool has_share = 14; } // 获取历史记录列表(旧版)-响应 message CursorReply { // 卡片内容 repeated CursorItem items = 1; // 顶部tab repeated CursorTab tab = 2; // 游标信息 Cursor cursor = 3; // 是否未拉取完 bool hasMore = 4; } // 获取历史记录列表(旧版)-请求 message CursorReq { // 游标信息 Cursor cursor = 1; // 业务类型 // all:全部 archive:视频 live:直播 article:专栏 string business = 2; // 秒开参数(旧版) PlayerPreloadParams player_preload = 3; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; } // 业务分类表 message CursorTab { // 业务类型 string business = 1; // 名称 string name = 2; // 路由uri string router = 3; // tab定位 bool focus = 4; } // 获取历史记录列表-响应 message CursorV2Reply { // 卡片内容 repeated CursorItem items = 1; // 游标信息 Cursor cursor = 2; // 是否未拉取完 bool hasMore = 3; // string empty_link = 4; } // 获取历史记录列表-请求 message CursorV2Req { // 游标信息 Cursor cursor = 1; // 业务类型 // archive:视频 live:直播 article:专栏 goods:商品 show:展演 string business = 2; // 秒开参数(旧版) PlayerPreloadParams player_preload = 3; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 4; // 是否选择本机的播放历史 bool is_local = 5; } // 设备标识代码 enum DT { Unknown = 0; // 未知 Phone = 1; // 手机端 Pad = 2; // ipad端 PC = 3; // web端 TV = 4; // TV端 Car = 5; // 车机端 Iot = 6; // 物联设备 AndPad = 7; // apad端 } // 删除历史记录-请求 message DeleteReq { // 历史记录信息 HisInfo his_info = 1; } // 设备类型 message DeviceType { // 设备标识代码 DT type = 1; // 图标url string icon = 2; } // 历史记录信息 message HisInfo { // 业务类型 // archive:视频 live:直播 article:专栏 goods:商品 show:展演 string business = 1; // 历史记录id int64 kid = 2; } // 搜索历史记录来源 enum HistorySource { history_VALUE = 0; // 主站历史记录页 shopping_VALUE = 1; // 会员购浏览记录 } // 获取历史记录tab-响应 message HistoryTabReply { // tab列表 repeated CursorTab tab = 1; } // 获取历史记录tab-请求 message HistoryTabReq { // 业务类型 // archive:视频 live:直播 article:专栏 goods:商品 show:展演 string business = 1; // 查询请求来源 HistorySource source = 2; // 搜索关键词 string keyword = 3; } // 获取最新的历史记录-响应 message LatestHistoryReply { // 卡片内容 CursorItem items = 1; // 场景 string scene = 2; // 弹窗停留时间 int64 rtime = 3; // 分组的标志(客户端埋点上报) string flag = 4; } // 获取最新的历史记录-请求 message LatestHistoryReq { // 业务类型 // archive:视频 live:直播 article:专栏 goods:商品 show:展演 string business = 1; // 秒开参数 PlayerPreloadParams player_preload = 2; } // 空响应 message NoReply { } // 页面信息 message Page { // 当前页码 int64 pn = 1; // 总计条目数 int64 total = 2; } // 秒开参数 message PlayerPreloadParams { //清晰度 int64 qn = 1; // 流版本 int64 fnver = 2; // 流类型 int64 fnval = 3; // 是否强制域名 int64 forceHost = 4; // 是否4K int64 fourk = 5; } // 关系信息 message Relation { // 关系状态 // 1:未关注 2:已关注 3:被关注 4:互关 int32 status = 1; // 用户关注UP主 int32 is_follow = 2; // UP主关注用户 int32 is_followed = 3; } // 搜索历史记录-响应 message SearchReply { // 卡片内容 repeated CursorItem items = 1; // 是否未拉取完 bool hasMore = 2; // 页面信息 Page page = 3; } // 搜索历史记录-请求 message SearchReq { // 关键词 string keyword = 1; // 页码 int64 pn = 2; // 业务类型 // archive:视频 live:直播 article:专栏 goods:商品 show:展演 string business = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/interfaces/v1/media.proto ================================================ syntax = "proto3"; package bilibili.app.interface.v1; option java_package = "bilibili.app.interfaces.v1"; option java_multiple_files = true; // service Media { // rpc MediaTab(MediaTabReq) returns (MediaTabReply); // rpc MediaDetail(MediaDetailReq) returns (MediaDetailReply); // rpc MediaVideo(MediaVideoReq) returns (MediaVideoReply); // rpc MediaRelation(MediaRelationReq) returns (MediaRelationReply); // rpc MediaFollow(MediaFollowReq) returns (MediaFollowReply); } // message BigItem { // string title = 1; // string cover_image_uri = 2; // string uri = 3; // string cover_right_text = 4; // string cover_left_text1 = 5; // int64 cover_left_icon1 = 6; // string cover_left_text2 = 7; // int64 cover_left_icon2 = 8; // UserCard user_card = 9; // LikeButton like_button = 10; // int64 param = 11; } // message Button { // string title = 1; // string link = 2; // string id = 3; // int64 icon = 4; // ButType but_type = 5; // int32 follow_state = 6; // string has_title = 7; } // enum ButType { BUT_INVALID = 0; // BUT_REDIRECT = 1; // BUT_LIKE = 2; // } // message Cast { // repeated MediaPerson person = 1; // string title = 2; } // message ChannelInfo { // int64 channel_id = 1; // bool subscribed = 2; } // message LikeButton { // int64 aid = 1; // int32 count = 2; // bool show_count = 3; // string event = 4; // int32 selected = 5; // string event_v2 = 6; // LikeButtonResource like_resource = 7; // LikeButtonResource dis_like_resource = 8; // LikeButtonResource like_night_resource = 9; // LikeButtonResource dis_like_night_resource = 10; } // message LikeButtonResource { // string url = 1; // string hash = 2; } // message LikeCard { // int64 like = 1; // bool is_follow = 2; } // message MediaCard { // string cover = 1; // string cur_title = 2; // string style = 3; // string label = 4; // Button but_first = 5; } // message MediaDetailReply { // Cast cast = 1; // Staff staff = 2; // Overview overview = 3; } // message MediaDetailReq { // int64 biz_id = 1; // int64 biz_type = 2; } // message MediaFollowReply { } // message MediaFollowReq { // string id = 1; // int32 type = 2; } // message MediaPerson { // string real_name = 1; // string square_url = 2; // string character = 3; // int64 person_id = 4; // string type = 5; } // message MediaRelationReply { // string offset = 1; // bool has_more = 2; // repeated SmallItem list = 3; } // message MediaRelationReq { // int64 biz_id = 1; // int64 biz_type = 2; // int64 feed_id = 3; // string offset = 5; // int32 ps = 6; } // message MediaTabReply { // MediaCard media_card = 1; // repeated ShowTab tab = 2; // int64 default_tab_index = 3; // ChannelInfo channel_info = 4; } // message MediaTabReq { // int64 biz_id = 1; // int64 biz_type = 2; // string source = 3; // string spmid = 4; // map args = 5; } // message MediaVideoReply { // string offset = 1; // bool has_more = 2; // repeated BigItem list = 3; } // message MediaVideoReq { // int64 biz_id = 1; // int64 biz_type = 2; // int64 feed_id = 3; // string offset = 5; // int32 ps = 6; } // message Overview { // string title = 1; // string text = 2; } // message ShowTab { // TabType tab_type = 1; // string title = 2; // string url = 3; } // message SmallItem { // string title = 1; // string cover_image_uri = 2; // string uri = 3; // string cover_right_text = 4; // string cover_left_text1 = 5; // int64 cover_left_icon1 = 6; // string cover_left_text2 = 7; // int64 cover_left_icon2 = 8; // int64 param = 9; // int64 mid = 10; } // message Staff { // string title = 1; // string text = 2; } // enum TabType { TAB_INVALID = 0; // TAB_OGV_DETAIL = 6; // TAB_OGV_REPLY = 7; // TAB_FEED_BID = 8; // TAB_FEED_SMALL = 9; // } // message UserCard { // string user_name = 1; // string user_face = 2; // string user_url = 3; // int64 mid = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/interfaces/v1/search.proto ================================================ syntax = "proto3"; package bilibili.app.interface.v1; option java_package = "bilibili.app.interfaces.v1"; option java_multiple_files = true; // 搜索 service Search { // 获取搜索建议 rpc Suggest3 (SuggestionResult3Req) returns (SuggestionResult3Reply); // rpc DefaultWords(DefaultWordsReq) returns (DefaultWordsReply); } // service SearchTest { // rpc NotExist(SuggestionResult3Req) returns (SuggestionResult3Reply); } // message DefaultWordsReply { // string trackid = 1; // string param = 2; // string show = 3; // string word = 4; // int64 show_front = 5; // string exp_str = 6; // string goto = 7; // string value = 8; // string uri = 9; } // message NftFaceIcon { // int32 region_type = 1; // string icon = 2; // int32 show_status = 3; } // message DefaultWordsReq { // int64 from = 1; // int64 login_event = 2; // int32 teenagers_mode = 3; // int32 lessons_mode = 4; // string tab = 5; // string event_id = 6; // string avid = 7; // string query = 8; // int64 an = 9; // int64 is_fresh = 10; } // 获取搜索建议-响应 message SuggestionResult3Reply { // 搜索追踪id string trackid = 1; // 搜索建议条目列表 repeated ResultItem list = 2; // 搜索的abtest 实验信息 string exp_str = 3; } // 获取搜索建议-请求 message SuggestionResult3Req { // 关键字 string keyword = 1; // 是否语法高亮 // 0:不显示 1:显示 int32 highlight = 2; // 是否青少年模式 // 1:开启青少年模式 int32 teenagers_mode = 3; } // 搜索建议条目 message ResultItem { // 来源 string from = 1; // 显示结果(语法高亮) string title = 2; // 搜索关键字 string keyword = 3; // 序号 int32 position = 4; // 图片 string cover = 5; // 图片尺寸 double cover_size = 6; // sug词类型 string sug_type = 7; // 词条大类型 int32 term_type = 8; // 跳转类型 string goto = 9; // 跳转uri string uri = 10; // 认证信息 OfficialVerify official_verify = 11; // 跳转参数 string param = 12; // up主mid int64 mid = 13; // 粉丝数 int32 fans = 14; // up主等级 int32 level = 15; // up主稿件数 int32 archives = 16; // 投稿时间 int64 ptime = 17; // season类型名称 string season_type_name = 18; // 地区 string area = 19; // 作品风格 string style = 20; // 描述信息 string label = 21; // 评分 double rating = 22; // 投票数 int32 vote = 23; // 角标 repeated ReasonStyle badges = 24; // string styles = 25; // int64 module_id = 26; // string live_link = 27; // int32 face_nft_new = 28; // NftFaceIcon nft_face_icon = 29; } // 认证信息 message OfficialVerify { // 认证类型 // 127:未认证 0:个人 1:机构 int32 type = 1; // 认证描述 string desc = 2; } // 角标 message ReasonStyle { // 角标文案 string text = 1; // 文案日间色值 string text_color = 2; // 文案夜间色值 string text_color_night = 3; // 背景日间色值 string bg_color = 4; // 背景夜间色值 string bg_color_night = 5; // 边框日间色值 string border_color = 6; // 边框夜间色值 string border_color_night = 7; // 角标样式 // 1:填充模式 2:镂空模式 int32 bg_style = 8; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/interfaces/v1/space.proto ================================================ syntax = "proto3"; package bilibili.app.interface.v1; option java_package = "bilibili.app.interfaces.v1"; option java_multiple_files = true; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/app/archive/v1/archive.proto"; import "bilibili/app/dynamic/v2/dynamic.proto"; // service Space { // rpc SearchTab(SearchTabReq) returns (SearchTabReply); // rpc SearchArchive(SearchArchiveReq) returns (SearchArchiveReply); // rpc SearchDynamic(SearchDynamicReq) returns (SearchDynamicReply); } // message Arc { // bilibili.app.archive.v1.Arc archive = 1; // string uri = 2; } // message Dynamic { // bilibili.app.dynamic.v2.DynamicItem dynamic = 1; } enum From { ArchiveTab = 0; // DynamicTab = 1; // } // message SearchTabReply { // int64 focus = 1; // repeated Tab tabs = 2; } // message SearchTabReq { // string keyword = 1; // int64 mid = 2; // int32 from = 3; } // message SearchArchiveReply { // repeated Arc archives = 1; // int64 total = 2; } // message SearchArchiveReq { // string keyword = 1; // int64 mid = 2; // int64 pn = 3; // int64 ps = 4; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 5; } // message SearchDynamicReply { // repeated Dynamic dynamics = 1; // int64 total = 2; } // message SearchDynamicReq { // string keyword = 1; // int64 mid = 2; // int64 pn = 3; // int64 ps = 4; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 5; } // message Tab { // string title = 1; // string uri = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/listener/v1/listener.proto ================================================ syntax = "proto3"; package bilibili.app.listener.v1; option java_multiple_files = true; import "google/protobuf/empty.proto"; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/app/interfaces/v1/history.proto"; import "bilibili/app/playurl/v1/playurl.proto"; // 听视频 service Listener { // rpc Ping (google.protobuf.Empty) returns (google.protobuf.Empty); // 获取音频URL rpc PlayUrl (PlayURLReq) returns (PlayURLResp); // rpc BkarcDetails (BKArcDetailsReq) returns (BKArcDetailsResp); // rpc Playlist (PlaylistReq) returns (PlaylistResp); // rpc PlaylistAdd (PlaylistAddReq) returns (google.protobuf.Empty); // rpc PlaylistDel (PlaylistDelReq) returns (google.protobuf.Empty); // 推荐列表 rpc RcmdPlaylist (RcmdPlaylistReq) returns (RcmdPlaylistResp); // rpc PlayHistory (PlayHistoryReq) returns (PlayHistoryResp); // 添加历史记录 rpc PlayHistoryAdd (PlayHistoryAddReq) returns (google.protobuf.Empty); // rpc PlayHistoryDel (PlayHistoryDelReq) returns (google.protobuf.Empty); // 播放上报 rpc PlayActionReport (PlayActionReportReq) returns (google.protobuf.Empty); // 三联 rpc TripleLike (TripleLikeReq) returns (TripleLikeResp); // 点赞 rpc ThumbUp (ThumbUpReq) returns (ThumbUpResp); // 投币 rpc CoinAdd (CoinAddReq) returns (CoinAddResp); // rpc FavItemAdd (FavItemAddReq) returns (FavItemAddResp); // rpc FavItemDel (FavItemDelReq) returns (FavItemDelResp); // 批量处理收藏 rpc FavItemBatch (FavItemBatchReq) returns (FavItemBatchResp); // rpc FavoredInAnyFolders (FavoredInAnyFoldersReq) returns (FavoredInAnyFoldersResp); // 用户收藏夹列表 rpc FavFolderList (FavFolderListReq) returns (FavFolderListResp); // 收藏夹详细信息 rpc FavFolderDetail (FavFolderDetailReq) returns (FavFolderDetailResp); // 创建收藏夹 rpc FavFolderCreate (FavFolderCreateReq) returns (FavFolderCreateResp); // rpc FavFolderDelete (FavFolderDeleteReq) returns (FavFolderDeleteResp); // 每日播单列表 rpc PickFeed (PickFeedReq) returns (PickFeedResp); // 每日播单详情 rpc PickCardDetail (PickCardDetailReq) returns (PickCardDetailResp); // rpc Medialist(MedialistReq) returns (MedialistResp); // rpc Event(EventReq) returns (EventResp); } // service Music { // rpc FavTabShow(FavTabShowReq) returns (FavTabShowResp); // rpc MainFavMusicSubTabList(MainFavMusicSubTabListReq) returns (MainFavMusicSubTabListResp); // rpc MainFavMusicMenuList(MainFavMusicMenuListReq) returns (MainFavMusicMenuListResp); // rpc MenuEdit(MenuEditReq) returns (MenuEditResp); // rpc MenuDelete(MenuDeleteReq) returns (MenuDeleteResp); // rpc MenuSubscribe(MenuSubscribeReq) returns (MenuSubscribeResp); // rpc Click(ClickReq) returns (ClickResp); } // message Author { // int64 mid = 1; // string name = 2; // string avatar = 3; // FollowRelation relation = 4; } // message BKArcDetailsReq { // repeated PlayItem items = 1; // bilibili.app.archive.middleware.v1.PlayerArgs playerArgs = 2; } // message BKArcDetailsResp { // repeated DetailItem list = 1; } // message BKArchive { // int64 oid = 1; // string title = 2; // string cover = 3; // string desc = 4; // int64 duration = 5; // int32 rid = 6; // string rname = 7; // int64 publish = 8; // string displayed_oid = 9; // int32 copyright = 10; // BKArcRights rights = 11; } // message BKArcPart { // int64 oid = 1; // int64 sub_id = 2; // string title = 3; // int64 duration = 4; // int32 page = 5; } // message BKArcRights { // int32 no_reprint = 1; } // message BKStat { // int32 like = 1; // int32 coin = 2; // int32 favourite = 3; // int32 reply = 4; // int32 share = 5; // int64 view = 6; // bool has_like = 7; // bool has_coin = 8; // bool has_fav = 9; } // message CardModule { // int32 module_type = 1; oneof module { // PkcmHeader module_header = 2; // PkcmArchive module_archive = 3; // PkcmCenterButton module_cbtn = 4; } } // enum CardModuleType { Module_invalid = 0; Module_header = 1; Module_archive = 2; Module_cbtn = 3; } // message ClickReq { // int64 sid = 1; // int32 action = 2; } // message ClickResp { } // message CoinAddReq { // PlayItem item = 1; // int32 num = 2; // bool thumb_up = 3; } // message CoinAddResp { // string message = 1; } // message DashItem { // int32 id = 1; // string base_url = 2; // repeated string backup_url = 3; // int32 bandwidth = 4; // string mime_type = 5; // string codecs = 6; // DashSegmentBase segment_base = 12; // int32 codecid = 13; // string md5 = 14; // int64 size = 15; } // message DashSegmentBase { // string initialization = 1; // string index_range = 2; } // message DetailItem { // PlayItem item = 1; // BKArchive arc = 2; // repeated BKArcPart parts = 3; // Author owner = 4; // BKStat stat = 5; // int64 last_part = 6; // int64 progress = 7; // int32 playable = 8; // string message = 9; // map player_info = 10; // PlayItem associated_item = 11; // int64 last_play_time = 12; // string history_tag = 13; // bilibili.app.interface.v1.DeviceType device_type = 14; // FavFolder ugc_season_info = 15; } // message EventReq { // int32 event_type = 1; // PlayItem item = 2; } // message EventResp { } // message EventTracking { // string operator = 1; // string batch = 2; // string track_id = 3; // string entity_type = 4; // string entity_id = 5; // string track_json = 6; } // message FavFolder { // int64 fid = 1; // int32 folder_type = 2; // FavFolderAuthor owner = 3; // string name = 4; // string cover = 5; // string desc = 6; // int32 count = 7; // int32 attr = 8; // int32 state = 9; // int32 favored = 10; // int64 ctime = 11; // int64 mtime = 12; // int32 stat_fav_cnt = 13; // int32 stat_share_cnt = 14; // int32 stat_like_cnt = 15; // int32 stat_Play_cnt = 16; // int32 stat_reply_cnt = 17; // int32 fav_state = 18; } // message FavFolderAction { // int64 fid = 1; // int32 folder_type = 2; // int32 action = 3; } // message FavFolderAuthor { // int64 mid = 1; // string name = 2; } // message FavFolderCreateReq { // string name = 1; // string desc = 2; // int32 public = 3; // int32 folder_type = 4; } // message FavFolderCreateResp { // int64 fid = 1; // int32 folder_type = 2; // string message = 3; } // message FavFolderDeleteReq { // int64 fid = 1; // int32 folder_type = 2; } // message FavFolderDeleteResp { // string message = 1; } // message FavFolderDetailReq { // int64 fid = 1; // int32 folder_type = 2; // int64 fav_mid = 3; // FavItem last_item = 4; // int32 page_size = 5; // bool need_folder_info = 6; } // message FavFolderDetailResp { // int32 total = 1; // bool reach_end = 2; // repeated FavItemDetail list = 3; // FavFolder folder_info = 4; } // message FavFolderListReq { // repeated int32 folder_types = 1; // PlayItem item = 2; } // message FavFolderListResp { // repeated FavFolder list = 1; } // message FavFolderMeta { // int64 fid = 1; // int32 folder_type = 2; } // message FavItem { // int32 item_type = 1; // int64 oid = 2; // int64 fid = 3; // int64 mid = 4; // int64 mtime = 5; // int64 ctime = 6; // EventTracking et = 7; } // message FavItemAddReq { // int64 fid = 1; // int32 folder_type = 2; oneof item { // PlayItem play = 3; // FavItem fav = 4; } } // message FavItemAddResp { // string message = 1; } // message FavItemAuthor { // int64 mid = 1; // string name = 2; } // message FavItemBatchReq { // repeated FavFolderAction actions = 1; oneof item { // PlayItem play = 2; // FavItem fav = 3; } } // message FavItemBatchResp { // string message = 1; } // message FavItemDelReq { // int64 fid = 1; // int32 folder_type = 2; oneof item { // PlayItem play = 3; // FavItem fav = 4; } } // message FavItemDelResp { // string message = 1; } // message FavItemDetail { // FavItem item = 1; // FavItemAuthor owner = 2; // FavItemStat stat = 3; // string cover = 4; // string name = 5; // int64 duration = 6; // int32 state = 7; // string message = 8; // int32 parts = 9; } // message FavItemStat { // int64 view = 1; // int32 reply = 2; } // message FavoredInAnyFoldersReq { // repeated int32 folder_types = 1; // PlayItem item = 2; } // message FavoredInAnyFoldersResp { // repeated FavFolderMeta folders = 1; // PlayItem item = 2; } // message FavTabShowReq { // int64 mid = 1; } // message FavTabShowResp { // bool show_menu = 1; } // message FollowRelation { // int32 status = 1; } // message FormatDescription { // int32 quality = 1; // string format = 2; // string description = 3; // string display_desc = 4; // string superscript = 5; } // enum ListOrder { NO_ORDER = 0; // ORDER_NORMAL = 1; // ORDER_REVERSE = 2; // ORDER_RANDOM = 3; // } // enum ListSortField { NO_SORT = 0; // SORT_CTIME = 1; // SORT_VIEWCNT = 2; // SORT_FAVCNT = 3; // } // message MainFavMusicMenuListReq { // int32 tab_type = 1; // string offset = 2; } // message MainFavMusicMenuListResp { // int32 tab_type = 1; // repeated MusicMenu menu_list = 2; // bool has_more = 3; // string offset = 4; } // message MainFavMusicSubTabListReq { } // message MainFavMusicSubTabListResp { // repeated MusicSubTab tabs = 1; // MainFavMusicMenuListResp default_tab_res = 2; // map first_page_res = 3; } // message MedialistItem { // PlayItem item = 1; // string title = 2; // string cover = 3; // int64 duration = 4; // int32 parts = 5; // int64 up_mid = 6; // string up_name = 7; // int32 state = 8; // string message = 9; // int64 stat_view = 10; // int64 stat_reply = 11; } // message MedialistReq { // int64 list_type = 1; // int64 biz_id = 2; // string offset = 3; } // message MedialistResp { // int64 total = 1; // bool has_more = 2; // string offset = 3; // repeated MedialistItem items = 4; // MedialistUpInfo up_info = 5; } // message MedialistUpInfo { // int64 mid = 1; // string avatar = 2; // int64 fans = 3; // string name = 4; } // message MenuDeleteReq { // int64 id = 1; } // message MenuDeleteResp { // string message = 1; } // message MenuEditReq { // int64 id = 1; // string title = 2; // string desc = 3; // int32 is_public = 4; } // message MenuEditResp { // string message = 1; } // message MenuSubscribeReq { // int32 action = 1; // int64 target_id = 2; } // message MenuSubscribeResp { // string message = 1; } // message MusicMenu { // int64 id = 1; // int32 menu_type = 2; // string title = 3; // string desc = 4; // string cover = 5; // MusicMenuAuthor owner = 6; // int32 state = 7; // int64 attr = 8; // MusicMenuStat stat = 9; // int64 total = 10; // int64 ctime = 11; // string uri = 12; } // message MusicMenuAuthor { // int64 mid = 1; // string name = 2; // string avatar = 3; } // message MusicMenuStat { // int64 play = 1; // int64 reply = 2; } // message MusicSubTab { // string name = 1; // int32 tab_type = 2; // int64 total = 3; } // message PageOption { // int32 page_size = 1; // int32 direction = 2; // PlayItem last_item = 3; } // message PickArchive { // PlayItem item = 1; // string title = 2; // PickArchiveAuthor owner = 3; // string cover = 4; // int64 duration = 5; // int32 parts = 6; // int32 stat_view = 7; // int32 stat_reply = 8; // int32 state = 9; // string message = 10; } // message PickArchiveAuthor { // int64 mid = 1; // string name = 2; } // message PickCard { // int64 pick_id = 1; // int64 card_id = 2; // string card_name = 3; // repeated CardModule modules = 4; } // message PickCardDetailReq { // int64 card_id = 1; // int64 pick_id = 2; } // message PickCardDetailResp { // int64 card_id = 1; // int64 pick_id = 2; // repeated CardModule modules = 3; } // message PickFeedReq { // int64 offset = 1; } // message PickFeedResp { // int64 offset = 1; // repeated PickCard cards = 2; } // message PkcmArchive { // PickArchive arc = 1; // string pick_reason = 2; } // message PkcmCenterButton { // string icon_head = 1; // string icon_tail = 2; // string title = 3; // string uri = 4; } // message PkcmHeader { // string title = 1; // string desc = 2; // string btn_icon = 3; // string btn_text = 4; // string btn_uri = 5; } // message PlayActionReportReq { // PlayItem item = 1; // string from_spmid = 2; } // message PlayDASH { // int32 duration = 1; // float min_buffer_time = 2; // repeated DashItem audio = 3; } // message PlayHistoryAddReq { // PlayItem item = 1; // int64 progress = 2; // int64 duration = 3; // int32 play_style = 4; } // message PlayHistoryDelReq { // repeated PlayItem items = 1; // bool truncate = 2; } // message PlayHistoryReq { // PageOption page_opt = 1; // int64 local_today_zero = 2; } // message PlayHistoryResp { // int32 total = 1; // bool reach_end = 2; // repeated DetailItem list = 3; } // message PlayInfo { // int32 qn = 1; // string format = 2; // int32 qn_type = 3; oneof info { // PlayURL play_url = 4; // PlayDASH play_dash = 5; } int32 fnver = 6; // int32 fnval = 7; // repeated int32 formats = 8; // int32 video_codecid = 9; // int64 length = 10; // int32 code = 11; // string message = 12; // int64 expire_time = 13; // bilibili.app.playurl.v1.VolumeInfo volume = 14; } // message PlayItem { // int32 item_type = 1; // int64 oid = 3; // repeated int64 sub_id = 4; // EventTracking et = 5; } // message PlaylistAddReq { // repeated PlayItem items = 1; oneof pos { // PlayItem after = 2; // bool head = 3; // bool tail = 4; } } // message PlaylistDelReq { // repeated PlayItem items = 1; // bool truncate = 2; } // message PlaylistReq { // int32 from = 1; // int64 id = 2; // PlayItem anchor = 3; // PageOption page_opt = 4; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 5; // int64 extra_id = 6; // SortOption sort_opt = 7; } // message PlaylistResp { // int32 total = 1; // bool reach_start = 2; // bool reach_end = 3; // repeated DetailItem list = 4; // PlayItem last_play = 5; // int64 last_progress = 6; } // enum PlaylistSource { DEFAULT = 0; // MEM_SPACE = 1; // AUDIO_COLLECTION = 2; // AUDIO_CARD = 3; // USER_FAVOURITE = 4; // UP_ARCHIVE = 5; // AUDIO_CACHE = 6; // PICK_CARD = 7; // MEDIA_LIST = 8; // } // message PlayURL { // repeated ResponseUrl durl = 1; } // message PlayURLReq { // PlayItem item = 1; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 2; } // message PlayURLResp { // PlayItem item = 1; // int32 playable = 2; // string message = 3; // map playerInfo = 4; } // message RcmdPlaylistReq { // int32 from = 1; // int64 id = 2; // bool need_history = 3; // bool need_top_cards = 4; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 5; } // message RcmdPlaylistResp { // repeated DetailItem list = 1; // int64 history_len = 2; // repeated TopCard top_cards = 3; } // message ResponseUrl { // int32 order = 1; // int64 length = 2; // int64 size = 3; // string ahead = 4; // string vhead = 5; // string url = 6; // repeated string backup_url = 7; // string md5 = 8; } // message SortOption { // int32 order = 1; // int32 sort_field = 2; } // message ThumbUpReq { // PlayItem item = 1; // int32 action = 2; } // message ThumbUpResp { // string message = 1; } // message TopCard { // string title = 1; // int32 play_style = 2; // int32 card_type = 3; // oneof card { // TpcdHistory listen_history = 4; // TpcdFavFolder fav_folder = 5; // TpcdUpRecall up_recall = 6; // TpcdPickToday pick_today = 7; } // int64 pos = 8; // string title_icon = 9; } // enum TopCardType { UNSPECIFIED = 0; // LISTEN_HISTORY = 1; // FAVORITE_FOLDER = 2; // UP_RECALL = 3; // PICK_TODAY = 4; // } // message TpcdFavFolder { // DetailItem item = 1; // string text = 2; // string pic = 3; // int64 fid = 4; // int32 folder_type = 5; } // message TpcdHistory { // DetailItem item = 1; // string text = 2; // string pic = 3; } // message TpcdPickToday { // DetailItem item = 1; // string text = 2; // string pic = 3; // int64 pick_id = 4; // int64 pick_card_id = 5; } // message TpcdUpRecall { // int64 up_mid = 1; // string text = 2; // string avatar = 3; // int64 medialist_type = 4; // int64 medialist_biz_id = 5; // DetailItem item = 6; } // message TripleLikeReq { // PlayItem item = 1; } // message TripleLikeResp { // string message = 1; // bool thumb_ok = 2; // bool coin_ok = 3; // bool fav_ok = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/playeronline/v1/playeronline.proto ================================================ syntax = "proto3"; package bilibili.app.playeronline.v1; option java_multiple_files = true; // 在线人数 service PlayerOnline { // 获取在线人数 rpc PlayerOnline (PlayerOnlineReq) returns (PlayerOnlineReply); // rpc PremiereInfo(PremiereInfoReq) returns (PremiereInfoReply); // rpc ReportWatch(ReportWatchReq) returns (NoReply); } // 空回复 message NoReply {} // 获取在线人数-回复 message PlayerOnlineReply { // string total_text = 1; // 下次轮询间隔时间 int64 sec_next = 2; // 是否底部显示 bool bottom_show = 3; // bool sdm_show = 4; // string sdm_text = 5; // int64 total_number = 6; // string total_number_text = 7; } // 获取在线人数-请求 message PlayerOnlineReq { // 稿件 avid int64 aid = 1; // 视频 cid int64 cid = 2; // 是否在播放中 bool play_open = 3; } // message PremiereInfoReply { // string premiere_over_text = 1; // int64 participant = 2; // int64 interaction = 3; } // message PremiereInfoReq { // int64 aid = 1; } // message ReportWatchReq { // int64 aid = 1; // string biz = 2; // string buvid = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/playerunite/pgcanymodel/PGCAnyModel.proto ================================================ syntax = "proto3"; package bilibili.app.playerunite.pgcanymodel; option java_multiple_files = true; import "bilibili/pgc/gateway/player/v2/playurl.proto"; message PGCAnyModel { bilibili.pgc.gateway.player.v2.PlayViewBusinessInfo business = 3; bilibili.pgc.gateway.player.v2.Event event = 4; bilibili.pgc.gateway.player.v2.ViewInfo view_info = 5; bilibili.pgc.gateway.player.v2.PlayAbilityExtConf play_ext_conf = 6; //bilibili.pgc.gateway.player.v2.PlayExtInfo play_ext_info = 7; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/playerunite/ugcanymodel/UGCAnyModel.proto ================================================ syntax = "proto3"; package bilibili.app.playerunite.ugcanymodel; option java_multiple_files = true; message ButtonStyle { string text = 1; string text_color = 2; string bg_color = 3; string jump_link = 4; } enum PlayLimitCode { PLC_UNKNOWN = 0; PLC_NOTPAYED = 1; } message PlayLimit { PlayLimitCode code = 1; string message = 2; string sub_message = 3; ButtonStyle button = 4; } message UGCAnyModel { PlayLimit play_limit = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/playerunite/v1/playerunite.proto ================================================ syntax = "proto3"; package bilibili.app.playerunite.v1; option java_multiple_files = true; import "bilibili/playershared/playershared.proto"; import "google/protobuf/any.proto"; // 统一视频url service Player { // 视频地址 rpc PlayViewUnite (PlayViewUniteReq) returns (PlayViewUniteReply); } // message PlayViewUniteReq { // 请求资源VOD信息 bilibili.playershared.VideoVod vod = 1; // string spmid = 2; // string from_spmid = 3; // 补充信息, 如ep_id等 map extra_content = 4; // string bvid = 5; } // message PlayViewUniteReply { // 音视频流信息 bilibili.playershared.VodInfo vod_info = 1; // bilibili.playershared.PlayArcConf play_arc_conf = 2; // bilibili.playershared.PlayDeviceConf play_device_conf = 3; // bilibili.playershared.Event event = 4; // 使用 pgcanymodel / ugcanymodel 进行proto any转换成对应业务码结构体 google.protobuf.Any supplement = 5; // bilibili.playershared.PlayArc play_arc = 6; // bilibili.playershared.QnTrialInfo qn_trial_info = 7; // bilibili.playershared.History history = 8; // bilibili.playershared.ViewInfo view_info = 9; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/playurl/v1/playurl.proto ================================================ syntax = "proto3"; package bilibili.app.playurl.v1; option java_multiple_files = true; // 视频url service PlayURL { // 视频地址 rpc PlayURL (PlayURLReq) returns (PlayURLReply); // 投屏地址 rpc Project (ProjectReq) returns (ProjectReply); // 播放页信息 rpc PlayView (PlayViewReq) returns (PlayViewReply); // 编辑播放界面配置 rpc PlayConfEdit (PlayConfEditReq) returns (PlayConfEditReply); // 获取播放界面配置 rpc PlayConf (PlayConfReq) returns (PlayConfReply); } // message AB { // Glance glance = 1; // int32 group = 2; } // 配置项 message ArcConf { // 是否支持 bool is_support = 1; // bool disabled = 2; // ExtraContent extra_content = 3; } // 业务类型 enum Business { UNKNOWN = 0; // 未知类型 STORY = 1; // story业务 } // Chronos灰度管理 message Chronos { // 资源md5 string md5 = 1; // 资源文件 string file = 2; } // message ButtonStyle { // string text = 1; // string text_color = 2; // string bg_color = 3; // string jump_link = 4; } // message CloudConf { // 是否展示功能 bool show = 1; // 设置类型 ConfType conf_type = 2; // FieldValue field_value = 3; // ConfValue conf_value = 4; } // 编码类型 enum CodeType { NOCODE = 0; // 默认 CODE264 = 1; // H.264 CODE265 = 2; // H.265 CODEAV1 = 3; // av1 } // 设置类型 enum ConfType { NoType = 0; // FLIPCONF = 1; // 镜像反转 CASTCONF = 2; // 视频投屏 FEEDBACK = 3; // 反馈 SUBTITLE = 4; // 字幕 PLAYBACKRATE = 5; // 播放速度 TIMEUP = 6; // 定时停止播放 PLAYBACKMODE = 7; // 播放方式 SCALEMODE = 8; // 画面尺寸 BACKGROUNDPLAY = 9; // 后台播放 LIKE = 10; // 顶 DISLIKE = 11; // 踩 COIN = 12; // 投币 ELEC = 13; // 充电 SHARE = 14; // 分享 SCREENSHOT = 15; // 截图 LOCKSCREEN = 16; // 锁屏 RECOMMEND = 17; // 推荐 PLAYBACKSPEED = 18; // 倍速 DEFINITION = 19; // 清晰度 SELECTIONS = 20; // 选集 NEXT = 21; // 下一集 EDITDM = 22; // 编辑弹幕 SMALLWINDOW = 23; // 小窗 SHAKE = 24; // 播放震动 OUTERDM = 25; // 外层面板弹幕设置 INNERDM = 26; // 三点内弹幕设置 PANORAMA = 27; // 全景 DOLBY = 28; // 杜比 COLORFILTER = 29; // 颜色滤镜 } // message ConfValue { oneof value { // bool switch_val = 1; // int64 selected_val = 2; } } // dash条目 message DashItem { // 清晰度 uint32 id = 1; // 主线流 string baseUrl = 2; // 备用流 repeated string backup_url = 3; // 带宽 uint32 bandwidth = 4; // 编码id uint32 codecid = 5; // md5 string md5 = 6; // 大小 uint64 size = 7; // 帧率 string frame_rate = 8; // string widevine_pssh = 9; } // dash视频流 message DashVideo { // 主线流 string base_url = 1; // 备用流 repeated string backup_url = 2; // 带宽 uint32 bandwidth = 3; // 编码id uint32 codecid = 4; // md5 string md5 = 5; // 大小 uint64 size = 6; // 伴音质量id uint32 audioId = 7; // 是否非全二压 bool no_rexcode = 8; // 码率 string frame_rate = 9; // 宽度 int32 width = 10; // 高度 int32 height = 11; // string widevine_pssh = 12; } // 杜比伴音信息 message DolbyItem { enum Type { NONE = 0; // NONE COMMON = 1; // 普通杜比音效 ATMOS = 2; // 全景杜比音效 } // 杜比类型 Type type = 1; // 音频流 DashItem audio = 2; } // 事件 message Event { // 震动 Shake shake = 1; } // message ExtraContent { // string disabled_reason = 1; // int64 disabled_code = 2; } // 配置字段值 message FieldValue { oneof value { // 开关 bool switch = 1; } } // 清晰度描述 message FormatDescription { // 清晰度 int32 quality = 1; // 清晰度格式 string format = 2; // 清晰度描述 string description = 3; // 新描述 string new_description = 4; // 选中态的清晰度描述 string display_desc = 5; // 选中态的清晰度描述的角标 string superscript = 6; } // message Glance { // bool can_watch = 1; // int64 times = 2; // int64 duration = 3; } // enum Group { UnknownGroup = 0; // A = 1; // B = 2; // C = 3; // } // 禁用功能配置 message PlayAbilityConf { CloudConf background_play_conf = 1; // 后台播放 CloudConf flip_conf = 2; // 镜像反转 CloudConf cast_conf = 3; // 投屏 CloudConf feedback_conf = 4; // 反馈 CloudConf subtitle_conf = 5; // 字幕 CloudConf playback_rate_conf = 6; // 播放速度 CloudConf time_up_conf = 7; // 定时停止 CloudConf playback_mode_conf = 8; // 播放方式 CloudConf scale_mode_conf = 9; // 画面尺寸 CloudConf like_conf = 10; // 赞 CloudConf dislike_conf = 11; // 踩 CloudConf coin_conf = 12; // 投币 CloudConf elec_conf = 13; // 充电 CloudConf share_conf = 14; // 分享 CloudConf screen_shot_conf = 15; // 截图 CloudConf lock_screen_conf = 16; // 锁定 CloudConf recommend_conf = 17; // 相关推荐 CloudConf playback_speed_conf = 18; // 播放速度 CloudConf definition_conf = 19; // 清晰度 CloudConf selections_conf = 20; // 选集 CloudConf next_conf = 21; // 下一集 CloudConf edit_dm_conf = 22; // 编辑弹幕 CloudConf small_window_conf = 23; // 小窗 CloudConf shake_conf = 24; // 震动 CloudConf outer_dm_conf = 25; // 外层面板弹幕设置 CloudConf innerDmDisable = 26; // 三点内弹幕设置 CloudConf inner_dm_conf = 27; // 一起看入口 CloudConf dolby_conf = 28; // 杜比音效 CloudConf color_filter_conf = 29; // 颜色滤镜 } // 播放控件稿件配置 message PlayArcConf { ArcConf background_play_conf = 1; // 后台播放 ArcConf flip_conf = 2; // 镜像反转 ArcConf cast_conf = 3; // 投屏 ArcConf feedback_conf = 4; // 反馈 ArcConf subtitle_conf = 5; // 字幕 ArcConf playback_rate_conf = 6; // 播放速度 ArcConf time_up_conf = 7; // 定时停止 ArcConf playback_mode_conf = 8; // 播放方式 ArcConf scale_mode_conf = 9; // 画面尺寸 ArcConf like_conf = 10; // 赞 ArcConf dislike_conf = 11; // 踩 ArcConf coin_conf = 12; // 投币 ArcConf elec_conf = 13; // 充电 ArcConf share_conf = 14; // 分享 ArcConf screen_shot_conf = 15; // 截图 ArcConf lock_screen_conf = 16; // 锁定 ArcConf recommend_conf = 17; // 相关推荐 ArcConf playback_speed_conf = 18; // 播放速度 ArcConf definition_conf = 19; // 清晰度 ArcConf selections_conf = 20; // 选集 ArcConf next_conf = 21; // 下一集 ArcConf edit_dm_conf = 22; // 编辑弹幕 ArcConf small_window_conf = 23; // 小窗 ArcConf shake_conf = 24; // 震动 ArcConf outer_dm_conf = 25; // 外层面板弹幕设置 ArcConf inner_dm_conf = 26; // 三点内弹幕设置 ArcConf panorama_conf = 27; // 一起看入口 ArcConf dolby_conf = 28; // 杜比音效 ArcConf screen_recording_conf = 29; // 屏幕录制 ArcConf color_filter_conf = 30; // 颜色滤镜 } // 编辑播放界面配置-响应 message PlayConfEditReply { } // 编辑播放界面配置-请求 message PlayConfEditReq { // 播放界面配置 repeated PlayConfState play_conf = 1; } // 获取播放界面配置-响应 message PlayConfReply { //播放控件用户自定义配置 PlayAbilityConf play_conf = 1; } // 获取播放界面配置-请求 message PlayConfReq { } // 播放界面配置 message PlayConfState { // 设置类型 ConfType conf_type = 1; // 是否隐藏 bool show = 2; // 配置字段值 FieldValue field_value = 3; // ConfValue conf_value = 4; } // 错误码 enum PlayErr { NoErr = 0; // WithMultiDeviceLoginErr = 1; // 管控类型的错误码 } // message PlayLimit { // PlayLimitCode code = 1; // string message = 2; // string sub_message = 3; // ButtonStyle button = 4; } // enum PlayLimitCode { PLCUnkown = 0; // PLCUgcNotPayed = 1; // } // 视频地址-回复 message PlayURLReply { // 清晰的 uint32 quality = 1; // 格式 string format = 2; // 总时长(单位为ms) uint64 timelength = 3; // 编码id uint32 video_codecid = 4; // 视频流版本 uint32 fnver = 5; // 视频流格式 uint32 fnval = 6; // 是否支持投影 bool video_project = 7; // 分段视频流列表 repeated ResponseUrl durl = 8; // dash数据 ResponseDash dash = 9; // 是否非全二压 int32 no_rexcode = 10; // 互动视频升级提示 UpgradeLimit upgrade_limit = 11; // 清晰度描述列表 repeated FormatDescription support_formats = 12; // 视频格式 VideoType type = 13; } // 视频地址-请求 message PlayURLReq { // 稿件avid int64 aid = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string from_spmid = 10; } // 播放页信息-回复 message PlayViewReply { // 视频流信息 VideoInfo video_info = 1; // 播放控件用户自定义配置 PlayAbilityConf play_conf = 2; // 互动视频升级提示 UpgradeLimit upgrade_limit = 3; // Chronos灰度管理 Chronos chronos = 4; // 播放控件稿件配置 PlayArcConf play_arc = 5; // 事件 Event event = 6; // AB ab = 7; // PlayLimit play_limit = 8; } // 播放页信息-请求 message PlayViewReq { // 稿件avid int64 aid = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string from_spmid = 10; // 青少年模式 int32 teenagers_mode = 11; // 编码 CodeType prefer_codec_type = 12; // 业务类型 Business business = 13; // int64 voice_balance = 14; } // 投屏地址-响应 message ProjectReply { PlayURLReply project = 1; } // 投屏地址-请求 message ProjectReq { // 稿件avid int64 aid = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string from_spmid = 10; // 使用协议 // 0:默认乐播 1:自建协议 2:云投屏 3:airplay int32 protocol = 11; // 投屏设备 // 0:默认其他 1:OTT设备 int32 device_type = 12; } // dash数据 message ResponseDash { // dash视频流 repeated DashItem video = 1; // dash伴音流 repeated DashItem audio = 2; } // 分段流条目 message ResponseUrl { // 分段序号 uint32 order = 1; // 分段时长 uint64 length = 2; // 分段大小 uint64 size = 3; // 主线流 string url = 4; // 备用流 repeated string backup_url = 5; // md5 string md5 = 6; } //分段视频流 message SegmentVideo { //分段视频流列表 repeated ResponseUrl segment = 1; } // 震动 message Shake { // 文件地址 string file = 1; } // 视频流信息 message Stream { // 元数据 StreamInfo stream_info = 1; // 流数据 oneof content { // dash流 DashVideo dash_video = 2; // 分段流 SegmentVideo segment_video = 3; } } // 流媒体元数据 message StreamInfo { // 清晰度 uint32 quality = 1; // 格式 string format = 2; // 格式描述 string description = 3; // 错误码 PlayErr err_code = 4; // 不满足条件信息 StreamLimit limit = 5; // 是否需要vip bool need_vip = 6; // 是否需要登录 bool need_login = 7; // 是否完整 bool intact = 8; // 是否非全二压 bool no_rexcode = 9; // 清晰度属性位 int64 attribute = 10; // 新版格式描述 string new_description = 11; // 格式文字 string display_desc = 12; // 新版格式描述备注 string superscript = 13; } // 清晰度不满足条件信息 message StreamLimit { // 标题 string title = 1; // 跳转地址 string uri = 2; // 提示信息 string msg = 3; } // 互动视频升级按钮信息 message UpgradeButton { // 标题 string title = 1; // 链接 string link = 2; } // 互动视频升级提示 message UpgradeLimit { // 错误码 int32 code = 1; // 错误信息 string message = 2; // 图片url string image = 3; // 按钮信息 UpgradeButton button = 4; } // 视频url信息 message VideoInfo { // 视频清晰度 uint32 quality = 1; // 视频格式 string format = 2; // 视频时长 uint64 timelength = 3; // 视频编码id uint32 video_codecid = 4; // 视频流 repeated Stream stream_list = 5; // 伴音流 repeated DashItem dash_audio = 6; // 杜比伴音流 DolbyItem dolby = 7; // VolumeInfo volume = 8; } // 视频类型 enum VideoType { Unknown_VALUE = 0; // FLV_VALUE = 1; // flv格式 DASH_VALUE = 2; // dash格式 MP4_VALUE = 3; // mp4格式 } // message VolumeInfo { // double measured_i = 1; // double measured_lra = 2; // double measured_tp = 3; // double measured_threshold = 4; // double target_offset = 5; // double target_i = 6; // double target_tp = 7; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/resource/privacy/v1/api.proto ================================================ syntax = "proto3"; package bilibili.app.resource.privacy.v1; option java_multiple_files = true; // 隐私 service Privacy { // 获取隐私设置 rpc PrivacyConfig(NoArgRequest) returns(PrivacyConfigReply); // 修改隐私设置 rpc SetPrivacyConfig(SetPrivacyConfigRequest) returns(NoReply); } // 空请求 message NoArgRequest{ } // 空响应 message NoReply{ } // 隐私设置 message PrivacyConfigItem { // 隐私开关类型 PrivacyConfigType privacy_config_type = 1; // string title = 2; // 隐私开关状态 PrivacyConfigState state = 3; // string sub_title = 4; // string sub_title_uri = 5; } // 获取隐私设置-响应 message PrivacyConfigReply { // 隐私设置 PrivacyConfigItem privacy_config_item = 1; } // 隐私开关状态 enum PrivacyConfigState { close = 0; // 关闭 open = 1; // 打开 } // 隐私开关类型 enum PrivacyConfigType { none = 0; // dynamic_city = 1; // 动态同城 } // 修改隐私设置-请求 message SetPrivacyConfigRequest { // 隐私开关类型 PrivacyConfigType privacy_config_type = 1; // 隐私开关状态 PrivacyConfigState state = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/resource/v1/module.proto ================================================ syntax = "proto3"; package bilibili.app.resource.v1; option java_multiple_files = true; // service Module { // rpc List(ListReq) returns (ListReply); } // enum CompressType { Unzip = 0; // unzip Original = 1; // 不操作 } // enum EnvType { Unknown = 0; // Release = 1; // Test = 2; // } // enum IncrementType { Total = 0; // 全量包 Incremental = 1; // 增量包 } // enum LevelType { Undefined = 0; // High = 1; // 高 需立即下载 Middle = 2; // 中 可以延迟下载 Low = 3; // 低 仅在业务方使用到时由业务方手动进行下载 } message ListReply { // string env = 1; // repeated PoolReply pools = 2; // int64 list_version = 3; } // message ListReq { // string pool_name = 1; // string module_name = 2; // repeated VersionListReq version_list = 3; // EnvType env = 4; // int32 sys_ver = 5; // int32 scale = 6; // int32 arch = 7; // int64 list_version = 8; } // message ModuleReply { // string name = 1; // int64 version = 2; // string url = 3; // string md5 = 4; // string total_md5 = 5; // IncrementType increment = 6; // bool is_wifi = 7; // LevelType level = 8; // string filename = 9; // string file_type = 10; // int64 file_size = 11; // CompressType compress = 12; // int64 publish_time = 13; // 上报使用 int64 pool_id = 14; // int64 module_id = 15; // int64 version_id = 16; // int64 file_id = 17; // bool zip_check = 18; } message PoolReply { // string name = 1; // repeated ModuleReply modules = 2; } // message VersionListReq { // string pool_name = 1; // repeated VersionReq versions = 2; } // message VersionReq { // string module_name = 1; // int64 version = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/search/v2/search.proto ================================================ syntax = "proto3"; package bilibili.app.search.v2; option java_multiple_files = true; import "bilibili/broadcast/message/main/search.proto"; service Search { // rpc CancelChatTask (CancelChatTaskReq) returns (CancelChatTaskReply); // rpc GetChatResult (GetChatResultReq) returns (bilibili.broadcast.message.main.ChatResult); // rpc SearchEgg (SearchEggReq) returns (SearchEggReply); // rpc SubmitChatTask (SubmitChatTaskReq) returns (SubmitChatTaskReply); } // message CancelChatTaskReq { // string session_id = 1; // string from_source = 2; } // message CancelChatTaskReply { // int32 code = 1; } // message GetChatResultReq { // string query = 1; // string session_id = 2; // string from_source = 3; } // message SearchEggInfo { // int32 egg_type = 1; // int64 id = 2; // int32 is_commercial = 3; // string mask_color = 4; // int64 mask_transparency = 5; // string md5 = 6; // int32 re_type = 7; // string re_url = 8; // string re_value = 9; // int32 show_count = 10; // int64 size = 11; // int64 source = 12; // string url = 13; } // message SearchEggInfos { // repeated SearchEggInfo egg_info = 1; } // message SearchEggReply { // int32 code = 1; // string seid = 2; // SearchEggInfos result = 3; } // message SearchEggReq { } // message SubmitChatTaskReply { // int32 code = 1; // string session_id = 2; } // message SubmitChatTaskReq { // string query = 1; // string track_id = 2; // string from_source = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/show/gateway/v1/service.proto ================================================ syntax = "proto3"; package bilibili.app.show.gateway.v1; option java_multiple_files = true; import "bilibili/broadcast/message/main/native.proto"; // service AppShow { // 获取Native页进度数据 rpc GetActProgress (GetActProgressReq) returns (GetActProgressReply); } // 获取Native页进度数据-请求 message GetActProgressReq { // Native页id int64 pageID = 1; // 用户mid int64 mid = 2; } // 获取Native页进度数据-响应 message GetActProgressReply { // 进度数据 bilibili.broadcast.message.main.NativePageEvent event = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/show/mixture/v1/mixture.proto ================================================ syntax = "proto3"; package bilibili.app.show.mixture.v1; option java_multiple_files = true; // service Mixture { // rpc Widget(WidgetReq) returns (WidgetReply); } // message RcmdReason { // string content = 1; // uint32 corner_mark = 2; } // message WidgetItem { // string cover = 1; // string view = 2; // RcmdReason rcmd_reason = 3; // string title = 4; // string name = 5; // string uri = 6; // string goto = 7; // int64 id = 8; // int32 view_icon = 9; } // message WidgetReply { // repeated WidgetItem item = 1; } // message WidgetReq { // string from_spmid = 1; // uint32 page_no = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/show/popular/v1/popular.proto ================================================ syntax = "proto3"; package bilibili.app.show.v1; option java_multiple_files = true; import "bilibili/app/card/v1/card.proto"; import "bilibili/app/archive/middleware/v1/preload.proto"; // 热门 service Popular { // 热门列表 rpc Index (PopularResultReq) returns (PopularReply); } // 气泡信息 message Bubble { // 文案 string bubble_content = 1; // 版本 int32 version = 2; // 起始时间 int64 stime = 3; } // 配置信息 message Config { // 标题 string item_title = 1; // 底部文案 string bottom_text = 2; // 底部图片url string bottom_text_cover = 3; // 底部跳转页url string bottom_text_url = 4; // 顶部按钮信息列表 repeated EntranceShow top_items = 5; // 头图url string head_image = 6; // 当前页按钮信息 repeated EntranceShow page_items = 7; // int32 hit = 8; } // 按钮信息 message EntranceShow { // 按钮图标url string icon = 1; // 按钮名 string title = 2; // 入口模块id string module_id = 3; // 跳转uri string uri = 4; // 气泡信息 Bubble bubble = 5; // 入口id int64 entrance_id = 6; // 头图url string top_photo = 7; // 入口类型 int32 entrance_type = 8; } // 热门列表-响应 message PopularReply { // 卡片列表 repeated bilibili.app.card.v1.Card items = 1; // 配置信息 Config config = 2; // 版本 string ver = 3; } // 热门列表-请求 message PopularResultReq { // 排位索引id,为上此请求末尾项的idx int64 idx = 1; // 登录标识 // 1:未登陆用户第一页 2:登陆用户第一页 int32 login_event = 2; // 清晰度(旧版) int32 qn = 3; // 视频流版本(旧版) int32 fnver = 4; // 视频流功能(旧版) int32 fnval = 5; // 是否强制使用域名(旧版) int32 force_host = 6; // 是否4K(旧版) int32 fourk = 7; // 当前页面spm string spmid = 8; // 上此请求末尾项的param string last_param = 9; // 上此请求的ver string ver = 10; // 分品类热门的入口ID int64 entrance_id = 11; // 热门定位id集合 string location_ids = 12; // 0:tag页 1:中间页 int32 source_id = 13; // 数据埋点上报 // 0:代表手动刷新 1:代表自动刷新 int32 flush = 14; // 秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 15; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/show/rank/v1/rank.proto ================================================ syntax = "proto3"; package bilibili.app.show.v1; option java_multiple_files = true; // 排行榜 service Rank { // 全站排行榜 rpc RankAll (RankAllResultReq) returns (RankListReply); // 分区排行榜 rpc RankRegion (RankRegionResultReq) returns (RankListReply); } // 排行榜列表项 message Item { // 标题 string title = 1; // 封面url string cover = 2; // 参数(稿件avid) string param = 3; // 跳转uri string uri = 4; // 重定向url string redirect_url = 5; // 跳转类型 // av:视频稿件 string goto = 6; // 播放数 int64 play = 7; // 弹幕数 int32 danmaku = 8; // UP主mid int64 mid = 9; // UP主昵称 string name = 10; // UP主头像url string face = 11; // 评论数 int32 reply = 12; // 收藏数 int32 favourite = 13; // 发布时间 int64 pub_date = 14; // 分区tid int32 rid = 15; // 子分区名 string rname = 16; // 视频总时长 int64 duration = 17; // 点赞数 int32 like = 18; // 1P cid int64 cid = 19; // 综合评分 int64 pts = 20; // 合作视频文案 string cooperation = 21; // 属性位 // 0:未关注 1:已关注 int32 attribute = 22; // UP主粉丝数 int64 follower = 23; // UP主认证信息 OfficialVerify official_verify = 24; // 同一UP收起子项列表 repeated Item children = 25; // 关系信息 Relation relation = 26; } // 认证信息 message OfficialVerify { // 认证类型 // -1:无认证 0:个人认证 1:机构认证 int32 type = 1; // 认证描述 string desc = 2; } // 全站排行榜-请求 message RankAllResultReq { // 必须为"all" string order = 1; // 页码 // 默认1页 int32 pn = 2; // 每页项数 // 默认100项,最大100 int32 ps = 3; } // 排行榜信息-响应 message RankListReply { // 排行榜列表 repeated Item items = 1; } // 分区排行榜-请求 message RankRegionResultReq { // 一级分区tid(二级分区不可用) // 0:全站 int32 rid = 1; // 页码 // 默认1页 int32 pn = 2; // 每页项数 // 默认100项,最大100 int32 ps = 3; } // 关系信息 message Relation { // 关系状态id // 1:未关注 2:已关注 3:被关注 4:互相关注 int32 status = 1; // 是否关注 int32 is_follow = 2; // 是否粉丝 int32 is_followed = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/show/region/v1/region.proto ================================================ syntax = "proto3"; package bilibili.app.show.region.v1; option java_multiple_files = true; // service Region { // rpc Region (RegionReq) returns (RegionReply); } // message RegionConfig { // string scenes_name = 1; // string scenes_type = 2; } // message RegionInfo { // int32 tid = 1; // int32 reid = 2; // string name = 3; // string logo = 4; // string goto = 5; // string param = 6; // string uri = 7; // int32 type = 8; // int32 is_bangumi = 9; // repeated RegionInfo children = 10; // repeated RegionConfig config = 11; } // message RegionReply { // repeated RegionInfo regions = 1; } // message RegionReq { // string lang = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/space/v1/space.proto ================================================ syntax = "proto3"; package bilibili.app.space.v1; option java_multiple_files = true; // service Space { // rpc Archive (ArchiveReq) returns (ArchiveReply); } //-响应 message ArchiveReply { // repeated BiliSpaceVideo item = 1; // int32 count = 2; // EpisodicButton episodic_button = 3; // repeated OrderConfig order = 4; } //-请求 message ArchiveReq { // int64 vmid = 1; // int32 pn = 2; // int32 ps = 3; // string order = 4; } // message Badge { // string text = 1; // string text_color = 2; // string text_color_night = 3; // string bg_color = 4; // string bg_color_night = 5; // string border_color = 6; // string border_color_night = 7; // int32 bg_style = 8; } // message BiliSpaceVideo { // string title = 1; // string tname = 2; // int64 duration = 3; // string cover = 4; // string uri = 5; // string param = 6; // string danmaku = 7; // int64 play = 8; // int64 ctime = 9; // bool state = 10; // bool is_popular = 11; // repeated Badge badges = 12; // string cover_right = 13; // string bvid = 14; // bool is_steins = 15; // bool is_ugcpay = 16; // bool is_cooperation = 17; } // message EpisodicButton { // string text = 1; // string uri = 2; } // message OrderConfig { // string title = 1; // string value = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/splash/v1/splash.proto ================================================ syntax = "proto3"; package bilibili.app.splash.v1; option java_multiple_files = true; import "google/protobuf/any.proto"; // service Splash { // rpc List (SplashReq) returns (SplashReply); } // message ShowStrategy { // int32 id = 1; // int64 stime = 2; // int64 etime = 3; } // message SplashItem { // int32 id = 1; // int32 type = 2; // int32 card_type = 3; // int32 duration = 4; // int64 begin_time = 5; // int64 end_time = 6; // string thumb = 7; // string hash = 8; // string logo_url = 9; // string logo_hash = 10; // string video_url = 11; // string video_hash = 12; // int32 video_width = 13; // int32 video_height = 14; // string schema = 15; // string schema_title = 16; // string schema_package_name = 17; // repeated string schema_callup_whiteList = 18; // int32 skip = 19; // string uri = 20; // string uri_title = 21; // int32 source = 22; // int32 cm_mark = 23; // string ad_cb = 24; // int64 resource_id = 25; // string request_id = 26; // string client_ip = 27; // bool is_ad = 28; // bool is_ad_loc = 29; // google.protobuf.Any extra = 30; // int64 card_index = 31; // int64 server_type = 32; // int64 index = 33; // string click_url = 34; // string show_url = 35; // int32 time_target = 36; // int32 encryption = 37; // bool enable_pre_download = 38; // bool enable_background_download = 39; } //-响应 message SplashReply { // int32 max_time = 1; // int32 min_interval = 2; // int32 pull_interval = 3; // repeated SplashItem list = 4; // repeated ShowStrategy show = 5; } //-请求 message SplashReq { // int32 width = 1; // int32 height = 2; // string birth = 3; // string ad_extra = 4; // string network = 5; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/topic/v1/topic.proto ================================================ syntax = "proto3"; package bilibili.app.topic.v1; option java_multiple_files = true; import "bilibili/app/dynamic/v2/dynamic.proto"; import "bilibili/app/card/v1/common.proto"; import "bilibili/app/archive/middleware/v1/preload.proto"; // service Topic { // rpc TopicDetailsAll(TopicDetailsAllReq) returns (TopicDetailsAllReply); // rpc TopicDetailsFold(TopicDetailsFoldReq) returns (TopicDetailsFoldReply); // rpc TopicSetDetails(TopicSetDetailsReq) returns (TopicSetDetailsReply); } // message ButtonMeta { // string text = 1; // string icon = 2; } // message DetailsTopInfo { // TopicInfo topic_info = 1; // User user = 2; // string stats_desc = 3; // bool has_create_jurisdiction = 4; // OperationContent operation_content = 5; // string head_img_url = 6; // string head_img_backcolor = 7; // int32 word_color = 8; // int32 mission_page_show_type = 9; // string mission_url = 10; // string mission_text = 11; // TopicSet topic_set = 12; } // message FoldCardItem { // int32 is_show_fold = 1; // int64 fold_count = 2; // string card_show_desc = 3; // string fold_desc = 4; } // message FunctionalCard { // repeated TopicCapsule capsules = 1; // TrafficCard traffic_card = 2; // GameCard game_card = 3; } // message GameCard { // int64 game_id = 1; // string game_icon = 2; // string game_name = 3; // string score = 4; // string game_tags = 5; // string notice = 6; // string game_link = 7; } // message InlineProgressBar { // string icon_drag = 1; // string icon_drag_hash = 2; // string icon_stop = 3; // string icon_stop_hash = 4; } // message LargeCoverInline { // bilibili.app.card.v1.Base base = 1; // string cover_left_text1 = 2; // int32 cover_left_icon1 = 3; // string cover_left_text2 = 4; // int32 cover_left_icon2 = 5; // RightTopLiveBadge right_top_live_badge = 6; // string extra_uri = 7; // InlineProgressBar inline_progress_bar = 8; // TopicThreePoint topic_three_point = 9; // string cover_left_desc = 10; // bool hide_danmu_switch = 11; // bool disable_danmu = 12; // int32 can_play = 13; // string duration_text = 14; // RelationData relation_data = 15; } // message LiveBadgeResource { // string text = 1; // string animation_url = 2; // string animation_url_hash = 3; // string background_color_light = 4; // string background_color_night = 5; // int64 alpha_light = 6; // int64 alpha_night = 7; // string font_color = 8; } // message OperationCard { oneof card { // LargeCoverInline large_cover_inline = 1; } } // message OperationContent { // OperationCard operation_card = 1; } // message PubLayer { // int32 show_type = 1; // string jump_link = 2; // ButtonMeta button_meta = 3; // bool close_pub_layer_entry = 4; } // message RelationData { // bool is_fav = 1; // bool is_coin = 2; // bool is_follow = 3; // bool is_like = 4; // int64 like_count = 5; } // message RightTopLiveBadge { // int64 live_status = 1; // LiveBadgeResource in_live = 2; // string live_stats_desc = 3; } // message SortContent { // int64 sort_by = 1; // string sort_name = 2; } // message ThreePointItem { // string title = 1; // string jump_url = 2; } // message TimeLineEvents { // int64 event_id = 1; // string title = 2; // string time_desc = 3; // string jump_link = 4; } // message TimeLineResource { // int64 time_line_id = 1; // string time_line_title = 2; // repeated TimeLineEvents time_line_events = 3; // bool has_more = 4; } // message TopicActivities { // repeated TopicActivity activity = 1; // string act_list_title = 2; } // message TopicActivity { // int64 activity_id = 1; // string activity_name = 2; // string jump_url = 3; // string icon_url = 4; } // message TopicCapsule { // string name = 1; // string jump_url = 2; // string icon_url = 3; } // message TopicCardItem { // int32 type = 1; // bilibili.app.dynamic.v2.DynamicItem dynamic_item = 2; // FoldCardItem ford_card_item = 3; // VideoSmallCardItem video_small_card_item = 4; } // message TopicCardList { // repeated TopicCardItem topic_card_items = 1; // string offset = 2; // bool has_more = 3; // TopicSortByConf topic_sort_by_conf = 4; } // enum TopicCardType { ILLEGAL_TYPE = 0; // DYNAMIC = 1; // FOLD = 2; // VIDEO_SMALL_CARD = 3; // } // message TopicDetailsAllReply { // DetailsTopInfo details_top_info = 1; // TopicActivities topic_activities = 2; // TopicCardList topic_card_list = 3; // FunctionalCard functional_card = 4; // PubLayer pub_layer = 5; // TimeLineResource time_line_resource = 6; // TopicServerConfig topic_server_config = 7; } // message TopicDetailsAllReq { // int64 topic_id = 1; // int64 sort_by = 2; // string offset = 3; // int32 page_size = 4; // int32 local_time = 5; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 6; // int32 need_refresh = 7; // string source = 8; // int32 topic_details_ext_mode = 9; } // enum TopicDetailsExtMode { MODE_ILLEGAL_TYPE = 0; // STORY = 1; // } // message TopicDetailsFoldReply { // TopicCardList topic_card_list = 1; // int64 fold_count = 2; } // message TopicDetailsFoldReq { // int64 topic_id = 1; // string offset = 2; // int32 page_size = 3; // int32 local_time = 4; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 5; // int64 from_sort_by = 6; } // message TopicInfo { // int64 id = 1; // string name = 2; // int64 uid = 3; // int64 view = 4; // int64 discuss = 5; // int64 fav = 6; // int64 dynamics = 7; // int32 state = 8; // string jump_url = 9; // string backcolor = 10; // bool is_fav = 11; // string description = 12; // int32 create_source = 13; // string share_pic = 14; // int64 share = 15; // int64 like = 16; // string share_url = 17; // bool is_like = 18; // int32 type = 19; // string stats_desc = 20; // string fixed_topic_icon = 21; } // message TopicServerConfig { // int64 pub_events_increase_threshold = 1; // int64 pub_events_hidden_timeout_threshold = 2; // int64 vert_online_refresh_time = 3; } // message TopicSet { // int64 set_id = 1; // string set_name = 2; // string jump_url = 3; // string desc = 4; } // message TopicSetDetailsReply { // TopicSetHeadInfo topic_set_head_info = 1; // repeated TopicInfo topic_info = 2; // bool has_more = 3; // string offset = 4; // TopicSetSortCfg sort_cfg = 5; } // message TopicSetDetailsReq { // int64 set_id = 1; // int64 sort_by = 2; // string offset = 3; // int32 page_size = 4; } message TopicSetHeadInfo { // TopicSet topic_set = 1; // string topic_cnt_text = 2; // string head_img_url = 3; // string mission_url = 4; // string mission_text = 5; // string icon_url = 6; // bool is_fav = 7; // bool is_first_time = 8; } // message TopicSetSortCfg { // int64 default_sort_by = 1; // repeated SortContent all_sort_by = 2; } // message TopicSortByConf { // int64 default_sort_by = 1; // repeated SortContent all_sort_by = 2; // int64 show_sort_by = 3; } // message TopicThreePoint { // repeated ThreePointItem dyn_three_point_items = 1; } // message TrafficCard { // string name = 1; // string jump_url = 2; // string icon_url = 3; // string base_pic = 4; // string benefit_point = 5; // string card_desc = 6; // string jump_title = 7; } // message User { // int64 uid = 1; // string face = 2; // string name = 3; // string name_desc = 4; } // message VideoCardBase { // string cover = 1; // string title = 2; // string up_name = 3; // int64 play = 4; // string jump_link = 5; // int64 aid = 6; } // message VideoSmallCardItem { // VideoCardBase video_card_base = 1; // string cover_left_badge_text = 2; // int64 card_stat_icon1 = 3; // string card_stat_text1 = 4; // int64 card_stat_icon2 = 5; // string card_stat_text2 = 6; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/view/v1/view.proto ================================================ syntax = "proto3"; package bilibili.app.view.v1; option java_multiple_files = true; import "google/protobuf/any.proto"; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/app/archive/v1/archive.proto"; import "bilibili/pagination/pagination.proto"; import "bilibili/app/viewunite/common.proto"; service View { // 视频页详情页 rpc View (ViewReq) returns (ViewReply); // rpc ViewTag(ViewTagReq) returns (ViewTagReply); // rpc ViewMaterial(ViewMaterialReq) returns (ViewMaterialReply); // 视频播放过程中的数据 rpc ViewProgress (ViewProgressReq) returns (ViewProgressReply); // 短视频下载 rpc ShortFormVideoDownload (ShortFormVideoDownloadReq) returns (ShortFormVideoDownloadReply); // 点击播放器卡片事件 rpc ClickPlayerCard (ClickPlayerCardReq) returns (NoReply); // 点击大型活动页预约 rpc ClickActivitySeason (ClickActivitySeasonReq) returns (NoReply); // 合集详情页 rpc Season (SeasonReq) returns (SeasonReply); // 播放器卡片曝光 rpc ExposePlayerCard (ExposePlayerCardReq) returns (NoReply); // 点击签订契约 rpc AddContract (AddContractReq) returns (NoReply); // 资源包 rpc ChronosPkg(ChronosPkgReq) returns (Chronos); // rpc CacheView(CacheViewReq) returns (CacheViewReply); // rpc ContinuousPlay(ContinuousPlayReq) returns (ContinuousPlayReply); // 播放页推荐IFS rpc RelatesFeed(RelatesFeedReq) returns (RelatesFeedReply); // rpc PremiereArchive(PremiereArchiveReq) returns (PremiereArchiveReply); // rpc Reserve(ReserveReq) returns (ReserveReply); // rpc PlayerRelates(PlayerRelatesReq) returns (PlayerRelatesReply); // rpc SeasonActivityRecord(SeasonActivityRecordReq) returns (SeasonActivityRecordReply); // rpc SeasonWidgetExpose(SeasonWidgetExposeReq) returns (SeasonWidgetExposeReply); // rpc GetArcsPlayer(GetArcsPlayerReq) returns (GetArcsPlayerReply); } // 活动页资源包 message ActivityResource { // mod资源池名称 string mod_pool_name = 1; // mod资源名称 string mod_resource_name = 2; // 背景色 string bg_color = 3; // 选中背景色 string selected_bg_color = 4; // 文字颜色 string text_color = 5; // 浅字色 string light_text_color = 6; // 深字色 string dark_text_color = 7; // 分割线色 string divider_color = 8; } // 大型活动合集 message ActivitySeason { // 稿件信息 bilibili.app.archive.v1.Arc arc = 1; // 分P信息 repeated ViewPage pages = 2; //("OnwerExt"为源码中拼写错误) OnwerExt owner_ext = 3; // 稿件用户操作状态 ReqUser req_user = 4; // 充电排行 ElecRank elec_rank = 5; // 历史观看进度 History history = 6; // 稿件bvid string bvid = 7; // 获得荣誉信息 Honor honor = 8; // 联合投稿成员列表 repeated Staff staff = 9; // UGC视频合集信息 UgcSeason ugc_season = 10; // 播放页定制tab Tab tab = 11; // 排行榜 Rank rank = 12; // 预约模块 Order order = 13; // 是否支持点踩 bool support_dislike = 14; // 相关推荐(运营配置+AI推荐) OperationRelate operation_relate = 15; // 活动页资源包 ActivityResource activity_resource = 16; // 短链接 string short_link = 17; // 标签 Label label = 18; // 不感兴趣原因 Dislike dislike = 19; // 播放图标动画配置档 PlayerIcon player_icon = 20; // 分享副标题(已观看xxx次) string share_subtitle = 21; // 广告配置 CMConfig cm_config = 22; // 免流面板定制 TFPanelCustomized tf_panel_customized = 23; // 争议信息 string argue_msg = 24; // 错误码 // DEFAULT:正常 CODE404:视频被UP主删除 ECode ecode = 25; // 404页信息 CustomConfig custom_config = 26; // 评论样式 string badge_url = 27; // 稿件简介v2 repeated DescV2 desc_v2 = 28; // Config config = 29; // Online online = 30; // ArcExtra arc_extra = 31; // ReplyStyle reply_preface = 32; } // 点击签订契约-请求 message AddContractReq { // 稿件avid int64 aid = 1; // UP主mid int64 up_mid = 2; // 当前页面spm string spmid = 3; } // message AdInfo { // int64 creative_id = 1; // int64 creative_type = 2; // CreativeContent creative_content = 3; // string ad_cb = 4; // int32 card_type = 5; // bytes extra = 6; } // message ArcExtra { // string arc_pub_location = 1; } // message ArcsPlayer { // int64 aid = 1; // map player_info = 2; } // message Asset { // int32 paid = 1; // int64 price = 2; // AssetMsg msg = 3; // AssetMsg preview_msg = 4; } // message AssetMsg { // string desc1 = 1; // string desc2 = 2; } // 关注按钮卡片 message Attention { // 开始时间 int32 start_time = 1; // 结束时间 int32 end_time = 2; // 位置x坐标 double pos_x = 3; // 位置y坐标 double pos_y = 4; } // 音频稿件信息 message Audio { // 音频标题 string title = 1; // 音频封面url string cover_url = 2; // 音频auid int64 song_id = 3; // 音频播放量 int64 play_count = 4; // 音频评论数 int64 reply_count = 5; // 音频作者UID int64 upper_id = 6; // 进入按钮文案 string entrance = 7; // int64 song_attr = 8; } // message BadgeStyle { // string text = 1; // string text_color = 2; // string text_color_night = 3; // string bg_color = 4; // string bg_color_night = 5; // string border_color = 6; // string border_color_night = 7; // int32 bg_style = 8; } // 视频引用的bgm音频 message Bgm { // 音频auid int64 sid = 1; // 音频作者mid int64 mid = 2; // 音频标题 string title = 3; // 音频作者昵称 string author = 4; // bgm页面url string jumpUrl = 5; // 音频封面url string cover = 6; } // 收藏合集参数 message BizFavSeasonParam { // 合集id int64 season_id = 1; } // message BizFollowVideoParam { // int64 season_id = 1; } // message BizJumpLinkParam { // 链接 string url = 1; } // 预约活动参数 message BizReserveActivityParam { // 活动id int64 activity_id = 1; // 场景 string from = 2; // 类型 string type = 3; // 资源id int64 oid = 4; // int64 reserve_id = 5; } // message BizReserveGameParam { // 游戏id int64 game_id = 1; } // 业务类型 enum BizType { BizTypeNone = 0; // BizTypeFollowVideo = 1; // 追番追剧 BizTypeReserveActivity = 2; // 预约活动 BizTypeJumpLink = 3; // 跳转链接 BizTypeFavSeason = 4; // 收藏合集 BizTypeReserveGame = 5; // 预约游戏 } // message Button { // 按钮文案 string title = 1; // 跳转uri string uri = 2; // string icon = 3; } // message ButtonStyle { // string text = 1; // string text_color = 2; // string text_color_night = 3; // string bg_color = 4; // string bg_color_night = 5; // string jump_link = 6; } // message BuzzwordConfig { // string name = 1; // string schema = 2; // int32 source = 3; // int64 start = 4; // int64 end = 5; // bool follow_control = 6; // int64 id = 7; // int64 buzzword_id = 8; // int32 schema_type = 9; // string picture = 10; } // message CacheViewReply { // bilibili.app.archive.v1.Arc arc = 1; // repeated ViewPage pages = 2; // OnwerExt owner_ext = 3; // ReqUser req_user = 4; // Season season = 5; // ElecRank elec_rank = 6; // History history = 7; // Dislike dislike = 8; // PlayerIcon player_icon = 9; // string bvid = 10; // string short_link = 11; // string share_subtitle = 12; // TFPanelCustomized tf_panel_customized = 13; // Online online = 14; } // message CacheViewReq { // int64 aid = 1; // string bvid = 2; // string from = 3; // string trackid = 4; // string ad_extra = 5; // string spmid = 6; // string from_spmid = 7; } // enum Category { CategoryUnknown = 0; // CategorySeason = 1; // } // Chronos灰度管理 message Chronos { // 资源包md5 string md5 = 1; // 资源包 string file = 2; // string sign = 3; } // message ChronosPkgReq { // string service_key = 1; // string engine_version = 2; // string message_protocol = 3; } // 点击大型活动页预约-请求 message ClickActivitySeasonReq { // 预约类型 BizType order_type = 1; // 当前页面spm string spmid = 2; // 业务参数 oneof order_param { // 预约活动参数 BizReserveActivityParam reserve = 3; // 收藏合集参数 BizFavSeasonParam fav_season = 4; } // 操作 // 0:操作 1:取消操作 int64 action = 5; } // 点击播放器卡片-响应 message ClickPlayerCardReply { // string message = 1; } // 点击播放器卡片-请求 message ClickPlayerCardReq { // 卡片id int64 id = 1; // 稿件avid int64 aid = 2; // 视频cid int64 cid = 3; //操作 //0:操作 1:取消操作 int64 action = 4; // 当前页面spm string spmid = 5; } // 广告 message CM { // 广告数据(需解包) google.protobuf.Any source_content = 1; } // 广告配置 message CMConfig { // 广告配置数据(需要二次解包) google.protobuf.Any ads_control = 1; } // message CmIpad { // CM cm = 1; // bilibili.app.archive.v1.Author author = 2; // bilibili.app.archive.v1.Stat stat = 3; // int64 duration = 4; // int64 aid = 5; } // message CoinCustom { // string toast = 1; } // 互动弹幕条目信息 message CommandDm { // 弹幕id int64 id = 1; // 对象视频cid int64 oid = 2; // 发送者mid int64 mid = 3; // 互动弹幕指令 string command = 4; // 互动弹幕正文 string content = 5; // 出现时间 int32 progress = 6; // 创建时间 string ctime = 7; // 发布时间 string mtime = 8; // 扩展json数据 string extra = 9; // 弹幕id str类型 string id_str = 10; } // message Config { // 下方推荐项标题 string relates_title = 1; // int32 relates_style = 2; // int32 relate_gif_exp = 3; // int32 end_page_half = 4; // int32 end_page_full = 5; // 退出是否自动小窗 bool auto_swindow = 6; // bool popup_info = 7; // string abtest_small_window = 8; // int32 rec_three_point_style = 9; // bool is_absolute_time = 10; // bool new_swindow = 11; // bool relates_biserial = 12; // ListenerConfig listener_conf = 13; // string relates_feed_style = 14; // bool relates_feed_popup = 15; // bool relates_has_next = 16; // int32 local_play = 17; // bool play_story = 18; // bool arc_play_story = 19; // string story_icon = 20; // bool landscape_story = 21; // bool arc_landscape_story = 22; // string landscape_icon = 23; // bool show_listen_button = 24; } // message ContinuousPlayReply { // repeated Relate relates = 1; } // message ContinuousPlayReq { // int64 aid = 1; // string from = 2; // string trackid = 3; // string spmid = 4; // string from_spmid = 5; // int32 autoplay = 6; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 7; // int64 device_type = 8; // string session_id = 9; // int64 display_id = 10; } // 契约卡 message ContractCard { // 需要触发的播放进度百分比 float display_progress = 1; // 触发位置的前后误差(单位ms) int64 display_accuracy = 2; // 展示持续时间(单位ms) int64 display_duration = 3; // 弹出模式 // 0: 原有模式 1: 半屏弹出 2: 全屏、半屏均弹出 int32 show_mode = 4; // 提示页面 // 0: 原有页面 1: 6.23版本新页面 int32 page_type = 5; // UP主信息 UpperInfos upper = 6; // int32 is_follow_display = 7; // ContractText text = 8; // int64 follow_display_end_duration = 9; // int32 is_play_display = 10; // int32 is_interact_display = 11; // bool play_display_switch = 12; } // message ContractText { // string title = 1; // string subtitle = 2; // string inline_title = 3; } // message CreativeContent { // string title = 1; // string description = 2; // string button_title = 3; // int64 video_id = 4; // string username = 5; // string image_url = 6; // string image_md5 = 7; // string log_url = 8; // string log_md5 = 9; // string url = 10; // string click_url = 11; // string show_url = 12; } // 404页信息 message CustomConfig { // 重定向页面url string redirect_url = 1; } // 枚举-文本类型 enum DescType { DescTypeUnknown = 0; // 占位 DescTypeText = 1; // 文本 DescTypeAt = 2; // @ } // 特殊稿件简介 message DescV2 { // 文本内容 string text = 1; // 文本类型 DescType type = 2; // 点击跳转链接 string uri = 3; // 资源ID int64 rid = 4; } // 不喜欢原因 message Dislike { // 标题 string title = 1; // string subtitle = 2; // 原因项列表 repeated DislikeReasons reasons = 3; } // 不喜欢原因项 message DislikeReasons { // 类型 // 1:全部类型 3:TAG 4:UP主 int64 id = 1; // 相关UP主mid int64 mid = 2; // 相关分区tid int32 rid = 3; // 相关TAG id int64 tag_id = 4; // 相关名称 string name = 5; } // 分P弹幕信息 message DM { // 分P是否关闭弹幕 // 0:正常 1:关闭 bool closed = 1; // bool real_name = 2; // 分P弹幕总数 int64 count = 3; } // 错误代码 enum ECode { DEFAULT = 0; // 正常 CODE404 = 1; // 稿件被UP主删除 } // 充电排行信息 message ElecRank { // 充电排行列表 repeated ElecRankItem list = 1; // 充电用户数 int64 count = 2; // string text = 3; } // 充电用户信息 message ElecRankItem { // 用户头像url string avatar = 1; // 用户昵称 string nickname = 2; // 充电留言 string message = 3; // 用户mid int64 mid = 4; } // 视频合集单话信息 message Episode { // 合集单话id int64 id = 1; // 稿件avid int64 aid = 2; // 视频1P cid int64 cid = 3; // 稿件标题 string title = 4; // 稿件封面url string cover = 5; // 投稿时间显示文案 string coverRightText = 6; // 视频分P信息 bilibili.app.archive.v1.Page page = 7; // 视频状态数 bilibili.app.archive.v1.Stat stat = 8; // 稿件bvid string bvid = 9; // 稿件UP主信息 bilibili.app.archive.v1.Author author = 10; // string author_desc = 11; // BadgeStyle badge_style = 12; // bool need_pay = 13; // bool episode_pay = 14; // bool free_watch = 15; // string first_frame = 16; // ArchiveStat stat_v2 = 17; // repeated bilibili.app.archive.v1.Page pages = 18; } // message ArchiveStat { // bilibili.app.viewunite.common.StatInfo view_vt = 11; } // 播放器卡片曝光-请求 message ExposePlayerCardReq { // 卡片类型 PlayerCardType card_type = 1; // 稿件avid int64 aid = 2; // 视频cid int64 cid = 3; // 当前页面spm string spmid = 4; } // message FeedViewItem { // ViewReply view = 1; // string goto = 2; // string uri = 3; // string track_id = 4; } // message FeedViewReply { // repeated FeedViewItem list = 1; // bool has_next = 2; } // message FeedViewReq { // int64 aid = 1; // string bvid = 2; // string from = 3; // string spmid = 4; // string from_spmid = 5; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 6; // int64 display_id = 7; // string session_id = 8; // string page_version = 9; // string from_track_id = 10; } // message GetArcsPlayerReply { // repeated ArcsPlayer arcs_player = 1; } // message GetArcsPlayerReq { // repeated PlayAv play_avs = 1; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 2; } // message GoodsInfo { // string goods_id = 1; // int32 category = 2; // int64 goods_price = 3; // PayState pay_state = 4; // string goods_name = 5; // string price_fmt = 6; } // 稿件观看进度 message History { // 播放进度分P cid int64 cid = 1; // 播放进度时间 // 0:未观看 -1:已看完 正整数:播放时间进度 int64 progress = 2; } // 稿件获得荣誉信息 message Honor { // 荣誉栏图标url string icon = 1; // 荣誉栏图标url 夜间模式 string icon_night = 2; // 荣誉文案 string text = 3; // 荣誉副文案 string text_extra = 4; // 标题颜色 string text_color = 5; // 标题颜色 夜间模式 string text_color_night = 6; // 背景颜色 string bg_color = 7; // 背景颜色 夜间模式 string bg_color_night = 8; // 跳转uri string url = 9; // 跳转角标文案 string url_text = 10; } // message IconData { // string meta_json = 1; // string sprits_img = 2; } // message Interaction { // Node history_node = 1; // int64 graph_version = 2; // string msg = 3; // string evaluation = 4; // int64 mark = 5; } // message Label { // int32 type = 1; // string uri = 2; // string icon = 3; // string icon_night = 4; // int64 icon_width = 5; // int64 icon_height = 6; // string lottie = 7; // string lottie_night = 8; } // message LikeAnimation { // string like_icon = 1; // string liked_icon = 2; // string like_animation = 3; } // message LikeCustom { // bool like_switch = 1; // int64 full_to_half_progress = 2; // int64 non_full_progress = 3; // int64 update_count = 4; } // message ListenerConfig { // int64 jump_style = 1; // ListenerGuideBar guide_bar = 2; } // message ListenerGuideBar { // int64 show_strategy = 1; // string icon = 2; // string text = 3; // string btn_text = 4; // int64 show_time = 5; // int64 background_time = 6; } // 直播信息 message Live { // 主播UID int64 mid = 1; // 直播间id int64 roomid = 2; // 直播间url string uri = 3; // string endpage_uri = 4; } // 直播预约信息 message LiveOrderInfo { // 预约id int64 sid = 1; // 预约条文案 string text = 2; // 直播开始时间 int64 live_plan_start_time = 3; // 是否预约 bool is_follow = 4; } // message MaterialLeft { // string icon = 1; // string text = 2; // string url = 3; // string left_type = 4; // string param = 5; // string operational_type = 6; // string static_icon = 7; } // message MaterialRes { // int64 id = 1; // string icon = 2; // string url = 3; // MaterialSource type = 4; // string name = 5; // string bg_color = 6; // string bg_pic = 7; // int32 jump_type = 8; } // enum MaterialSource { Default = 0; // BiJian = 1; // 必剪 } // message Node { // int64 node_id = 1; // string title = 2; // int64 cid = 3; } // 空回复 message NoReply {} // message Notice { // string title = 1; // string desc = 2; } // 认证信息 message OfficialVerify { // 认证类型 // 0:个人认证 1:官方认证 int32 type = 1; //认证名称 string desc = 2; } // message Online { // bool online_show = 1; // string player_online_logo = 2; } // UP主扩展信息 ("OnwerExt"为源码中拼写错误) message OnwerExt { // 认证信息 OfficialVerify official_verify = 1; // 直播信息 Live live = 2; // 会员信息 Vip vip = 3; // repeated int64 assists = 4; // 粉丝数 int64 fans = 5; // 总投稿数 string arc_count = 6; } // 老运营卡片 message OperationCard { // 开始时间(单位为秒) int32 start_time = 1; // 结束时间(单位为秒) int32 end_time = 2; // 图标 string icon = 3; // 标题 string title = 4; // 按钮文案 string button_text = 5; // 跳转链接 string url = 6; // 内容描述 string content = 7; } // 内嵌操作按钮卡片 message OperationCardNew { // 卡片id int64 id = 1; // 开始时间 int32 from = 2; // 结束时间 int32 to = 3; // 用户操作态 // true已操作 false未操作 bool status = 4; // 卡片类型 OperationCardType card_type = 5; // 卡片渲染 oneof render { // 标准卡 StandardCard standard = 6; // 老运营卡片(原B剪跳转卡) OperationCard skip = 7; } // BizType biz_type = 8; // oneof param { // 追番追剧参数 BizFollowVideoParam follow = 9; // 预约活动参数 BizReserveActivityParam reserve = 10; // 跳转参数 BizJumpLinkParam jump = 11; // 预约游戏参数 BizReserveGameParam game = 12; } } // 卡片样式 enum OperationCardType { CardTypeNone = 0; // CardTypeStandard = 1; // 标准卡 CardTypeSkip = 2; // 原跳转卡 } // message OperationCardV2 { // int64 id = 1; // int32 from = 2; // int32 to = 3; // bool status = 4; // int32 biz_type = 5; // OperationCardV2Content content = 6; // oneof param { // BizFollowVideoParam BizFollowVideoParam = 7; // BizReserveActivityParam BizReserveActivityParam = 8; // BizJumpLinkParam BizJumpLinkParam = 9; // BizReserveGameParam BizReserveGameParam = 10; } } // message OperationCardV2Content { // string title = 1; // string subtitle = 2; // string icon = 3; // string button_title = 4; // string button_selected_title = 5; // bool show_selected = 6; } // 相关推荐(运营配置+AI推荐) message OperationRelate { // 模块标题 string title = 1; // 相关推荐模块内容 repeated RelateItem relate_item = 2; // AI相关推荐 repeated Relate ai_relate_item = 3; } // 预约模块 message Order { // 用户操作态 bool status = 1; // 模块标题 string title = 2; // 按钮文字 未操作 string button_title = 3; // 按钮文字 已操作 string button_selected_title = 4; // 合集播放数 int64 season_stat_view = 5; // 合集弹幕数 int64 season_stat_danmaku = 6; // 预约类型(点击时透传,直播开始前预约活动,直播开始后收藏合集) BizType order_type = 7; // 预约业务参数 oneof order_param { // 预约活动参数 BizReserveActivityParam reserve = 8; // 收藏合集参数 BizFavSeasonParam fav_season = 9; } // 合集简介 string intro = 10; } // 游戏礼包信息 message PackInfo { // 礼包标题 string title = 1; // 礼包页uri string uri = 2; } // enum PayState { PayStateUnknown = 0; // PayStateActive = 1; // } // message PlayAv { // int64 aid = 1; // int64 cid = 2; } // 卡片类型 enum PlayerCardType { PlayerCardTypeNone_VALUE = 0; // PlayerCardTypeAttention_VALUE = 1; // 关注卡 PlayerCardTypeOperation_VALUE = 2; // 运营卡 PlayerCardTypeContract_VALUE = 3; // 契约卡 } // 进度条动画配置 message PlayerIcon { // 拖动动画配置档url string url1 = 1; // 拖动动画配置档hash string hash1 = 2; // 松手动画配置档url string url2 = 3; // 松手动画配置档hash string hash2 = 4; // string drag_left_png = 5; // string middle_png = 6; // string drag_right_png = 7; // IconData drag_data = 8; // IconData nodrag_data = 9; } // message PlayerRelatesReply { // repeated Relate list = 1; } // message PlayerRelatesReq { // int64 aid = 1; // string bvid = 2; // string from = 3; // string spmid = 4; // string from_spmid = 5; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 6; // string session_id = 7; // string from_track_id = 8; } // message PointMaterial { // string url = 1; // int32 material_source = 2; } // message PowerIconStyle { // string icon_url = 1; // string icon_night_url = 2; // int64 icon_width = 3; // int64 icon_height = 4; } // message Premiere { // PremiereState premiere_state = 1; // int64 start_time = 2; // int64 service_time = 3; // int64 room_id = 4; } // message PremiereArchiveReply { // Premiere premiere = 1; // bool risk_status = 2; // string risk_reason = 3; } // message PremiereArchiveReq { // int64 aid = 1; } // message PremiereReserve { // int64 reserve_id = 1; // int64 count = 2; // bool is_follow = 3; } // message PremiereResource { // Premiere premiere = 1; // PremiereReserve reserve = 2; // PremiereText text = 3; } // enum PremiereState { premiere_none = 0; // premiere_before = 1; // premiere_in = 2; // premiere_after = 3; // } // message PremiereText { // string title = 1; // string subtitle = 2; // string online_text = 3; // string online_icon = 4; // string online_icon_dark = 5; // string intro_title = 6; // string intro_icon = 7; // string guidance_pulldown = 8; // string guidance_entry = 9; // string intro_icon_night = 10; } // message PullClientAction { // string type = 1; // bool pull_action = 2; // string params = 3; } // 排行榜 message Rank { // 排行榜icon string icon = 1; // 排行榜icon 夜间模式 string icon_night = 2; // 排行榜文案 string text = 3; } // message RankInfo { // string icon_url_night = 1; // string icon_url_day = 2; // string bkg_night_color = 3; // string bkg_day_color = 4; // string font_night_color = 5; // string font_day_color = 6; // string rank_content = 7; // string rank_link = 8; } // 推荐理由样式 message ReasonStyle { // string text = 1; // 日间模式文字 string text_color = 2; // string bg_color = 3; // string border_color = 4; // 夜间模式文字 string text_color_night = 5; // string bg_color_night = 6; // string border_color_night = 7; // 1:填充 2:描边 3:填充 + 描边 4:背景不填充 + 背景不描边 int32 bg_style = 8; // int32 selected = 9; } // message RecDislike { // string title = 1; // string sub_title = 2; // string closed_sub_title = 3; // string paste_text = 4; // string closed_paste_text = 5; // repeated DislikeReasons dislike_reason = 6; // string toast = 7; // string closed_toast = 8; } // message RecThreePoint { // RecDislike dislike = 1; // RecDislike feedback = 2; // bool watch_later = 3; } // message RefreshPage { // int32 refreshable = 1; // int32 refresh_icon = 2; // string refresh_text = 3; // float refresh_show = 4; } // 相关推荐项 message Relate { // int64 aid = 1; // 封面url string pic = 2; // 标题 string title = 3; // UP主信息 bilibili.app.archive.v1.Author author = 4; // 稿件状态数 bilibili.app.archive.v1.Stat stat = 5; // 时长 int64 duration = 6; // 跳转类型 // special:pgc视频 av:稿件视频 cm:广告 game:游戏 string goto = 7; // 参数(如av号等) string param = 8; // 跳转uri string uri = 9; // string jump_url = 10; // 评分 double rating = 11; // string reserve = 12; // 来源标识 // operation:管理员添加 string from = 13; // 备注 string desc = 14; // string rcmd_reason = 15; // 标志文字 string badge = 16; // 1P cid int64 cid = 17; // int32 season_type = 18; // int32 rating_count = 19; // 标签文案 string tag_name = 20; // 游戏礼包信息 PackInfo pack_info = 21; // Notice notice = 22; // 按钮信息 Button button = 23; // spm追踪id string trackid = 24; // 游戏卡片新样式 int32 new_card = 25; // 推荐理由样式 ReasonStyle rcmd_reason_style = 26; // string cover_gif = 27; // 广告 CM cm = 28; // 游戏卡字段 // 0:下载 1:预约(跳过详情) 2:预约 3:测试 4:测试+预约 5:跳过详情页 int64 reserve_status = 29; // string rcmd_reason_extra = 30; // RecThreePoint rec_three_point = 31; // string unique_id = 32; // int64 material_id = 33; // int64 from_source_type = 34; // string from_source_id = 35; // bilibili.app.archive.v1.Dimension dimension = 36; // string cover = 37; // ReasonStyle badge_style = 38; // PowerIconStyle power_icon_style = 39; // string reserve_status_text = 40; // string dislike_report_data = 41; // RankInfo rank_info_game = 42; // string first_frame = 43; } // 相关推荐内容 message RelateItem { // 跳链 string url = 1; // 封面 string cover = 2; } // 播放页推荐IFS-响应 message RelatesFeedReply { // repeated Relate list = 1; // bool has_next = 2; // bilibili.pagination.PaginationReply pagination = 3; } // 播放页推荐IFS-请求 message RelatesFeedReq { // int64 aid = 1; // string bvid = 2; // string from = 3; // string spmid = 4; // string from_spmid = 5; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 6; // int64 relates_page = 7; // string session_id = 8; // int32 autoplay = 9; // string from_track_id = 10; // string biz_extra = 11; // int64 device_type = 12; // string ad_extra = 13; // bilibili.pagination.Pagination pagination = 14; // int32 refresh_num = 15; } // message RelateTab { // string id = 1; // string title = 2; } // message ReplyStyle { // string badge_url = 1; // string badge_text = 2; // int64 badge_type = 3; } // 用户操作状态 message ReqUser { // 用户是否关注UP int32 attention = 1; // UP是否关注用户 int32 guest_attention = 2; // 是否收藏 int32 favorite = 3; // 是否点赞 int32 like = 4; // 是否点踩 int32 dislike = 5; // 是否投币 int32 coin = 6; // 关注等级 int32 attention_level = 7; // 是否收藏合集 int32 fav_season = 8; // Button elec_plus_btn = 9; } // message ReserveReply { // int64 reserve_id = 1; } // message ReserveReq { // int64 reserve_id = 1; // int64 reserve_action = 2; // int64 up_id = 3; } // message Restriction { // bool is_teenagers = 1; // bool is_lessons = 2; // bool is_review = 3; // bool disable_rcmd = 4; } // 剧集信息 message Season { // string allow_download = 1; // 剧集ssid int64 season_id = 2; // 是否重定向跳转 int32 is_jump = 3; // 剧集标题 string title = 4; // 剧集封面url string cover = 5; // 剧集是否完结 int32 is_finish = 6; // 最新一话epid int64 newest_ep_id = 7; // 最新一话标题 string newest_ep_index = 8; // 总集数 int64 total_count = 9; // 更新星期日 int32 weekday = 10; // 用户追番标志 UserSeason user_season = 11; // SeasonPlayer player = 12; // 单集页面url string ogv_playurl = 13; } // message SeasonActivityRecordReply { // UgcSeasonActivity activity = 1; } // message SeasonActivityRecordReq { // int64 season_id = 1; // int64 activity_id = 2; // int32 action = 3; // int64 aid = 4; // int64 cid = 5; // int64 scene = 6; // string spmid = 7; } // message SeasonPlayer { // int64 aid = 1; // string vid = 2; // int64 cid = 3; // string from = 4; } // 合集详情页-响应 message SeasonReply { // 合集信息 UgcSeason season = 1; } // 合集详情页-请求 message SeasonReq { // 合集id int64 season_id = 1; } // message SeasonShow { // string button_text = 1; // string join_text = 2; // string rule_text = 3; // string checkin_text = 4; // string checkin_prompt = 5; } // enum SeasonType { Unknown = 0; // Base = 1; // Good = 2; // } // message SeasonWidgetExposeReply { // int64 season_id = 1; // int64 activity_id = 2; } // message SeasonWidgetExposeReq { // int64 mid = 1; // int32 type = 2; // int64 season_id = 3; // int64 activity_id = 4; // int64 aid = 5; // int64 cid = 6; // int64 scene = 7; } // 视频合集小节信息 message Section { // 小节id int64 id = 1; // 小节标题 string title = 2; // 小节类型 // 0:其他 1:正片 int64 type = 3; // 单话列表 repeated Episode episodes = 4; } // 短视频下载-响应 message ShortFormVideoDownloadReply { // 是否有下载分享按钮 bool has_download_url = 1; // 下载url string download_url = 2; // 文件md5 string md5 = 3; // 文件大小(单位为Byte) int64 size = 4; // string backup_download_url = 5; } // 短视频下载-请求 message ShortFormVideoDownloadReq { // 稿件avid int64 aid = 1; // 视频cid int64 cid = 2; // 用户mid int64 mid = 3; // 设备buvid string buvid = 4; // 移动端包类型 string mobi_app = 5; // 移动端版本号 int64 build = 6; // 运行设备 string device = 7; // 平台 string platform = 8; // 当前页面spm string spmid = 9; // Restriction restriction = 10; // string tf_isp = 11; } // message SpecialCell { // string icon = 1; // string icon_night = 2; // string text = 3; // string text_color = 4; // string text_color_night = 5; // string jump_url = 6; // string cell_type = 7; // string cell_bgcolor = 8; // string cell_bgcolor_night = 9; // string param = 10; // string page_title = 11; // string jump_type = 12; // string end_icon = 13; // string end_icon_night = 14; // int64 notes_count = 15; } // 合作成员信息 message Staff { // 成员mid int64 mid = 1; // 成员角色 string title = 2; // 成员头像url string face = 3; // 成员昵称 string name = 4; // 成员官方信息 OfficialVerify official_verify = 5; // 成员会员信息 Vip vip = 6; // 是否关注该成员 int32 attention = 7; // int32 label_style = 8; } // 标准卡 message StandardCard { // 卡片文案 string title = 1; // 按钮文字 未操作 string button_title = 2; // 按钮文字 已操作 string button_selected_title = 3; // 已操作态是否显示 bool show_selected = 4; } // 免流子面板定制化配置 message subTFPanel { // 右侧按钮素材 string right_btn_img = 1; // 右侧按钮文案 string right_btn_text = 2; // 右侧按钮字体颜色 string right_btn_text_color = 3; // 右侧按钮跳转链接 string right_btn_link = 4; // 中心主文案内容 string main_label = 5; // 运营商 string operator = 6; } // TAB message Tab { // 背景图片 string background = 1; // 跳转类型 TabOtype otype = 2; // 类型id int64 oid = 3; // 跳转url string uri = 4; // 样式 TabStyle style = 5; // 文字 string text = 6; // 未选中态字色 string text_color = 7; // 选中态字色 string text_color_selected = 8; // 图片 string pic = 9; // 后台配置自增 int64 id = 10; // google.protobuf.Any ad_tab_info = 11; } // TAB跳转类型 enum TabOtype { UnknownOtype = 0; // 未知类型 URL = 1; // url链接 TopicNA = 2; // native话题活动 CmURI = 3; // 广告url } // TAB样式 enum TabStyle { UnknownStyle = 0; // 未知样式 Text = 1; // 文字样式 Pic = 2; // 图片样式 } // TAG信息 message Tag { // TAD id int64 id = 1; // TAG名 string name = 2; // int64 likes = 3; // int64 hates = 4; // int32 liked = 5; // int32 hated = 6; // TAG页面uri string uri = 7; // TAG类型 // common:普通 new:话题 act:活动 string tag_type = 8; } // 免流面板定制 message TFPanelCustomized { // 右侧按钮素材 string right_btn_img = 1; // 右侧按钮文案 string right_btn_text = 2; // 右侧按钮字体颜色 string right_btn_text_color = 3; // 右侧按钮跳转链接 string right_btn_link = 4; // 中心主文案内容 string main_label = 5; // 运营商(cm ct cu) string operator = 6; // 子面板定制化配置 map sub_panel = 7; } // TAG图标信息 message TIcon { // TAG图标url string icon = 1; } // UGC视频合集信息 message UgcSeason { // 合集id int64 id = 1; // 合集标题 string title = 2; // 合集封面url string cover = 3; // 合集简介 string intro = 4; // 小节列表 repeated Section sections = 5; // 合集状态数 UgcSeasonStat stat = 6; // 标签字色 string label_text = 7; // 标签背景色 string label_text_color = 8; // 标签夜间字色 string label_bg_color = 9; // 标签夜间背景色 string label_text_night_color = 10; // 右侧描述文案 string label_bg_night_color = 11; // 按钮文案 string descRight = 12; // 分集总数 int64 ep_count = 13; // 合集类型 SeasonType season_type = 14; // bool show_continual_button = 15; // int64 ep_num = 16; // bool season_pay = 17; // GoodsInfo goods_info = 18; // ButtonStyle pay_button = 19; // string label_text_new = 20; // UgcSeasonActivity activity = 21; // repeated string season_ability = 22; } // message UgcSeasonActivity { // int32 type = 1; // int64 oid = 2; // int64 activity_id = 3; // string title = 4; // string intro = 5; // int32 day_count = 6; // int32 user_count = 7; // int64 join_deadline = 8; // int64 activity_deadline = 9; // int32 checkin_view_time = 10; // bool new_activity = 11; // UserActivity user_activity = 12; // SeasonShow season_show = 13; } // ugc视频合集状态数 message UgcSeasonStat { // 合集id int64 season_id = 1; // 观看数 int64 view = 2; // 弹幕数 int32 danmaku = 3; // 评论数 int32 reply = 4; // 收藏数 int32 fav = 5; // 投币数 int32 coin = 6; // 分享数 int32 share = 7; // 当前排名 int32 now_rank = 8; // 历史最高排名 int32 his_rank = 9; // 总计点赞 int32 like = 10; } // message UpAct { // int64 sid = 1; // int64 mid = 2; // string title = 3; // string statement = 4; // string image = 5; // string url = 6; // string button = 7; } // message UpLikeImg { // string pre_img = 1; // string suc_img = 2; // string content = 3; // int64 type = 4; } // UP主信息 message UpperInfos { // 粉丝数 int64 fans_count = 1; // 近半年投稿数 int64 arc_count_last_half_year = 2; // 成为UP主时间 int64 first_up_dates = 3; // 总播放量 int64 total_play_count = 4; } // message UserActivity { // int32 user_state = 1; // int64 last_checkin_date = 2; // int32 checkin_today = 3; // int32 user_day_count = 4; // int32 user_view_time = 5; // string portrait = 6; } // 用户装扮信息 message UserGarb { // 点赞动画url string url_image_ani_cut = 1; // string like_toast = 2; } // 用户追番标志 message UserSeason { // 关注状态 // 0:未关注 1:已关注 string attention = 1; } // 视频引导信息 message VideoGuide { // 关注按钮卡片 repeated Attention attention = 1; // 互动弹幕 repeated CommandDm commandDms = 2; // 运营卡片 repeated OperationCard operation_card = 3; // 运营卡片新版 repeated OperationCardNew operation_card_new = 4; // 契约卡 ContractCard contract_card = 5; // repeated OperationCardV2 cards_second = 6; } // message VideoPoint { // int32 type = 1; // int64 from = 2; // int64 to = 3; // string content = 4; // string cover = 5; // string logo_url = 6; } // message VideoShot { // string pv_data = 1; // int32 img_x_len = 2; // int32 img_y_len = 3; // int32 img_x_size = 4; // int32 img_y_size = 5; // repeated string image = 6; } // message ViewMaterial { // int64 oid = 1; // int64 mid = 2; // string title = 3; // string author = 4; // string jump_url = 5; } // message ViewMaterialReply { // repeated MaterialRes material_res = 1; // MaterialLeft material_left = 2; } // message ViewMaterialReq { // int64 aid = 1; // string bvid = 2; // int64 cid = 3; } // 分P信息 message ViewPage { // 分P基本信息 bilibili.app.archive.v1.Page page = 1; // 分P对应的音频稿件 Audio audio = 2; // 分P弹幕信息 DM dm = 3; // 下载文案 string download_title = 4; // 分P完整标题(视频标题+分P标题) string download_subtitle = 5; } // 稿件播放中数据-回复 message ViewProgressReply { // 视频引导信息 VideoGuide video_guide = 1; // Chronos灰度管理 Chronos chronos = 2; // 视频快照 VideoShot arc_shot = 3; // repeated VideoPoint points = 4; // PointMaterial point_material = 5; // bool point_permanent = 6; // 名词解释列表 repeated BuzzwordConfig buzzword_periods = 7; } // 稿件播放中数据-请求 message ViewProgressReq { // 稿件avid int64 aid = 1; // 视频cid int64 cid = 2; // UP主mid int64 up_mid = 3; // string engine_version = 4; // string message_protocol = 5; // string service_key = 6; } // 视频页信息-响应 message ViewReply { // 稿件信息 bilibili.app.archive.v1.Arc arc = 1; // 分P信息 repeated ViewPage pages = 2; // UP主扩展信息 ("OnwerExt"为源码中拼写错误) OnwerExt owner_ext = 3; // 稿件用户操作状态 ReqUser req_user = 4; // 稿件TAG repeated Tag tag = 5; // TAG对应的图标 map t_icon = 6; // 稿件映射的PGC剧集信息 Season season = 7; // 充电排行 ElecRank elec_rank = 8; // 历史观看进度 History history = 9; // 视频相关推荐列表 repeated Relate relates = 10; // 不感兴趣原因 Dislike dislike = 11; // 播放图标动画配置档 PlayerIcon player_icon = 12; // string vip_active = 13; // 稿件bvid string bvid = 14; // 获得荣誉信息 Honor honor = 15; // 相关推荐顶部tab repeated RelateTab relate_tab = 16; // 参与的活动页面url string activity_url = 17; // 稿件引用bgm列表 repeated Bgm bgm = 18; // 联合投稿成员列表 repeated Staff staff = 19; // 争议信息 string argue_msg = 20; // 短链接 string short_link = 21; // 播放实验 // 1:相关推荐自动播放 int32 play_param = 22; // 标签 Label label = 23; // UGC视频合集信息 UgcSeason ugc_season = 24; // 配置信息 Config config = 25; // 分享副标题(已观看xxx次) string share_subtitle = 26; // 互动视频信息 Interaction interaction = 27; // 错误码 // DEFAULT:正常 CODE404:视频被UP主删除 ECode ecode = 28; // 404页信息 CustomConfig custom_config = 29; // 广告 repeated CM cms = 30; // 广告配置 CMConfig cm_config = 31; // 播放页定制tab Tab tab = 32; // 排行榜 Rank rank = 33; // 免流面板定制 TFPanelCustomized tf_panel_customized = 34; // UP主发起活动 UpAct up_act = 35; // 用户装扮 UserGarb user_garb = 36; // 大型活动合集 ActivitySeason activity_season = 37; // 评论样式 string badge_url = 38; // 直播预约信息 LiveOrderInfo live_order_info = 39; // 稿件简介v2 repeated DescV2 desc_v2 = 40; // CmIpad cm_ipad = 41; // repeated ViewMaterial sticker = 42; // UpLikeImg up_like_img = 43; // LikeCustom like_custom = 44; // repeated Tag desc_tag = 45; // SpecialCell special_cell = 46; // Online online = 47; // google.protobuf.Any cm_under_player = 48; // repeated ViewMaterial video_source = 49; // repeated SpecialCell special_cell_new = 50; // PremiereResource premiere = 51; // bool refresh_special_cell = 52; // MaterialLeft material_left = 53; // int64 notes_count = 54; // PullClientAction pull_action = 55; // ArcExtra arc_extra = 56; // bilibili.pagination.PaginationReply pagination = 57; // LikeAnimation like_animation = 58; // ReplyStyle reply_preface = 59; // RefreshPage refresh_page = 60; // CoinCustom coin_custom = 61; } // 视频页详情页-请求 message ViewReq { // 稿件avid(av/bv任选其一) int64 aid = 1; // 稿件bvid(av/bv任选其一) string bvid = 2; // 来源 string from = 3; // AI trackid string trackid = 4; // 广告扩展数据 string ad_extra = 5; // 清晰度(旧版) int32 qn = 6; // 流版本(旧版) int32 fnver = 7; // 流类型(旧版) int32 fnval = 8; // 是否强制使用域名(旧版) int32 force_host = 9; // 是否允许4K(旧版) int32 fourk = 10; // 当前页面spm string spmid = 11; // 上一页面spm string from_spmid = 12; // int32 autoplay = 13; // 视频秒开参数 bilibili.app.archive.middleware.v1.PlayerArgs player_args = 14; // string page_version = 15; // string biz_extra = 16; // int64 device_type = 17; // int64 relates_page = 18; // string session_id = 19; // int32 in_feed_play = 20; // string play_mode = 21; // bilibili.pagination.Pagination pagination = 22; // int32 refresh = 23; // int32 refresh_num = 24; } // message ViewTagReply { // repeated SpecialCell special_cell_new = 1; // MaterialLeft material_left = 2; } // message ViewTagReq { // int64 aid = 1; // string bvid = 2; // int64 cid = 3; // string spmid = 4; } // 会员信息 message Vip { //会员类型 int32 type = 1; //到期时间 int64 due_date = 2; // string due_remark = 3; // int32 access_status = 4; //会员状态 int32 vip_status = 5; // string vip_status_warn = 6; // int32 theme_type = 7; // VipLabel label = 8; } // 会员类型标签 message VipLabel { // string path = 1; // string text = 2; // string label_theme = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/viewunite/common.proto ================================================ syntax = "proto3"; package bilibili.app.viewunite.common; option java_multiple_files = true; import "bilibili/dagw/component/avatar/v1/avatar.proto"; import "bilibili/pagination/pagination.proto"; import "google/protobuf/any.proto"; // message Activity { // int32 id = 1; // string title = 2; // string link = 3; // string cover = 4; // int32 type = 5; // string ab = 6; // string show_name = 7; // string picurl = 8; // string picurl_selected = 9; // string h5_link = 10; // string jump_mode = 11; // repeated Item items = 12; } // message ActivityEntrance { // string activity_cover = 1; // string activity_title = 2; // string word_tag = 3; // string activity_subtitle = 4; // string activity_link = 5; // int32 activity_type = 6; // int32 reserve_id = 7; // int32 status = 8; // repeated User upper_list = 9; // map report = 10; } // message ActivityEntranceModule { // repeated ActivityEntrance activity_entrance = 1; } // message ActivityReserve { // string title = 1; // StatInfo vt = 2; // StatInfo danmaku = 3; // ReserveButton button = 4; } // message ActivityResource { // string mod_pool_name = 1; // string mod_resource_name = 2; } // message ActivityTab { // int32 id = 1; // string title = 2; // int32 type = 3; // string show_name = 4; // string picurl = 5; // string picurl_selected = 6; // string h5_link = 7; // string link = 8; // int32 link_type = 9; // int64 biz_key = 10; // string desc = 11; // string act_ext = 12; // map report = 13; } // message AggEpCard { // string title = 1; // string cover = 2; // string icon = 3; // int32 num = 4; // string jump_url = 5; } // message AggEps { // repeated AggEpCard agg_ep_cards = 1; // int32 place_index = 2; } // message AttentionRecommend {} // enum AttentionRelationStatus { // ARS_NONE = 0; // ARS_N0RELATION = 1; // ARS_FOLLOWHIM = 2; // ARS_FOLLOWME = 3; // ARS_BUDDY = 4; // ARS_SPECIAL = 5; // ARS_CANCELBLOCK = 6; } // message Audio { // map audio_info = 1; } // message AudioInfo { // string title = 1; // string cover_url = 2; // int64 song_id = 3; // int64 play_count = 4; // int64 reply_count = 5; // int64 upper_id = 6; // string entrance = 7; // int64 song_attr = 8; } // message BadgeInfo { // string text = 1; // string text_color = 2; // string text_color_night = 3; // string bg_color = 4; // string bg_color_night = 5; // string border_color = 6; // string border_color_night = 7; // int32 bg_style = 8; // string img = 9; // int32 type = 10; } // message Banner { // string title = 1; // repeated RelateItem relate_item = 2; } // message BizFavParam { // int64 season_id = 1; } // message BizReserveActivityParam { // int64 activity_id = 1; // string from = 2; // string type = 3; // int64 oid = 4; // int64 reserve_id = 5; } // message Button { // string title = 1; // string left_strikethrough_text = 2; // string type = 3; // string link = 4; // BadgeInfo badge_info = 5; // string sub_title = 6; } // message CardBasicInfo { // string title = 1; // string desc = 2; // string cover = 3; // string uri = 4; // string track_id = 5; // string unique_id = 6; // int64 from_source_type = 7; // string from_source_id = 8; // int64 material_id = 9; // string cover_gif = 10; // Owner author = 11; // int64 id = 12; // string from = 13; // string from_spmid_suffix = 14; // string report_flow_data = 15; } // message CardStyle { // int32 id = 1; // string name = 2; } // message Celebrity { // int32 id = 1; // string name = 2; // string role = 3; // string avatar = 4; // string short_desc = 5; // string desc = 6; // string character_avatar = 7; // string link = 8; // int64 mid = 9; // int32 is_follow = 10; // string occupation_name = 11; // int32 occupation_type = 12; // int32 relate_attr = 13; // string small_avatar = 14; // map report = 15; } // message CharacterGroup { // string title = 1; // repeated Celebrity characters = 2; } // message Characters { // repeated CharacterGroup groups = 1; } // message CoinExtend { // string coin_app_zip_icon = 1; // string coin_app_icon_1 = 2; // string coin_app_icon_2 = 3; // string coin_app_icon_3 = 4; // string coin_app_icon_4 = 5; } // message CombinationEp { // int32 id = 1; // int32 section_id = 2; // string title = 3; // int32 can_ord_desc = 4; // string more = 5; // repeated int32 episode_ids = 6; // repeated ViewEpisode episodes = 7; // string split_text = 8; // Style module_style = 9; // repeated SerialSeason serial_season = 10; // SectionData section_data = 11; } // message Covenanter {} // message DeliveryData { // string title = 1; // Style module_style = 2; // string more = 3; // oneof data { // Activity activity = 4; // Characters characters = 5; // TheatreHotTopic theatre_hot_topic = 6; // AggEps agg_eps = 7; } // int32 id = 8; // map report = 9; } // message Desc { // string info = 1; // string title = 2; } enum DescType { // DescTypeUnknown = 0; // DescTypeText = 1; // DescTypeAt = 2; } // message DescV2 { // string text = 1; // int32 type = 2; // string uri = 3; // int64 rid = 4; } // message Dimension { // int64 width = 1; // int64 height = 2; // int64 rotate = 3; } // message DislikeReasons { // int64 id = 1; // int64 mid = 2; // int32 rid = 3; // int64 tag_id = 4; // string name = 5; } // message FollowLayer { // Staff staff = 1; // Desc desc = 2; // map report = 3; } // message Headline { // Label label = 1; // string content = 2; } // message HistoryNode { // int64 node_id = 1; // string title = 2; // int64 cid = 3; } // 荣誉 Banner message Honor { // string icon = 1; // string icon_night = 2; // string text = 3; // string text_extra = 4; // string text_color = 5; // string text_color_night = 6; // string bg_color = 7; // string bg_color_night = 8; // string url = 9; // string url_text = 10; // HonorType type = 11; // HonorJumpType honor_jump_type = 12; // map report = 13; } // 荣誉 Banner 跳转类型 enum HonorJumpType { // HONOR_JUMP_TYPE_UNKNOWN = 0; // HONOR_OPEN_URL = 1; // HONOR_HALF_SCREEN = 2; } // 荣誉类型 enum HonorType { // HONOR_NONE = 0; // PLAYLET = 1; // 视频存在争议 ARGUE = 2; // NOTICE = 3; // GUIDANCE = 4; // 哔哩哔哩榜 HONOR_BILI_RANK = 5; // 周榜 HONOR_WEEKLY_RANK = 6; // 日榜 HONOR_DAILY_RANK = 7; // HONOR_CHANNEL = 8; // 音乐榜? HONOR_MUSIC = 9; // HONOR_REPLY = 10; } // message IconFont { // string name = 1; // string text = 2; } // message Interaction { // int64 ep_id = 1; // HistoryNode history_node = 2; // int64 graph_version = 3; // string msg = 4; // bool is_interaction = 5; } // message Item { // string link = 1; // string cover = 2; } // message KingPos { // bool disable = 1; // string icon = 2; // KingPositionType type = 3; // string disable_toast = 4; // string checked_post = 5; // oneof extend { // LikeExtend like = 6; // CoinExtend coin = 7; } } // message KingPosition { // repeated KingPos king_pos = 1; // repeated KingPos extenf = 2; } // enum KingPositionType { // KING_POS_UNSPECIFIED = 0; // LIKE = 1; // DISLIKE = 2; // COIN = 3; // FAV = 4; // SHARE = 5; // CACHE = 6; // DANMAKU = 7; } // message Label { // int32 type = 1; // string uri = 2; // string icon = 3; // string icon_night = 4; // int64 icon_width = 5; // int64 icon_height = 6; // string lottie = 7; // string lottie_night = 8; } // message LikeComment { // string reply = 1; // string title = 2; } // message LikeExtend { // UpLikeImg triple_like = 1; // string like_animation = 2; // PlayerAnimation player_animation = 3; // ActivityResource resource = 4; } // message Live { // int64 mid = 1; // int64 room_id = 2; // string uri = 3; // string endpage_uri = 4; } // 直播预约信息 message LiveOrder { // int64 sid = 1; // string text = 2; // int64 live_plan_start_time = 3; // bool is_follow = 4; // int64 follow_count = 5; } // message Mine { // double amount = 1; // int32 rank = 2; // string msg = 3; } // message Module { // ModuleType type = 1; // oneof data { // OgvIntroduction ogv_introduction = 2; // UgcIntroduction ugc_introduction = 3; // KingPosition king_position = 4; // Headline head_line = 5; // OgvTitle ogv_title = 6; // Honor honor = 7; // UserList list = 8; // Staffs staffs = 9; // ActivityReserve activity_reserve = 10; // LiveOrder live_order = 11; // SectionData section_data = 12; // DeliveryData delivery_data = 13; // FollowLayer follow_layer = 14; // OgvSeasons ogv_seasons = 15; // UgcSeasons ugc_season = 16; // OgvLiveReserve ogv_live_reserve = 17; // CombinationEp combination_ep = 18; // Sponsor sponsor = 19; // ActivityEntranceModule activity_entrance_module = 20; // SerialSeason serial_season = 21; // Relates relates = 22; // Banner banner = 23; // Audio audio = 24; // LikeComment like_comment = 25; // AttentionRecommend attention_recommend = 26; // Covenanter covenanter = 27; } } enum ModuleType { // UNKNOWN = 0; // OGV_INTRODUCTION = 1; // OGV_TITLE = 2; // UGC_HEADLINE = 3; // UGC_INTRODUCTION = 4; // KING_POSITION = 5; // MASTER_USER_LIST = 6; // STAFFS = 7; // HONOR = 8; // OWNER = 9; // PAGE = 10; // ACTIVITY_RESERVE = 11; // LIVE_ORDER = 12; // POSITIVE = 13; // SECTION = 14; // RELATE = 15; // PUGV = 16; // COLLECTION_CARD = 17; // ACTIVITY = 18; // CHARACTER = 19; // FOLLOW_LAYER = 20; // OGV_SEASONS = 21; // UGC_SEASON = 22; // OGV_LIVE_RESERVE = 23; // COMBINATION_EPISODE = 24; // SPONSOR = 25; // ACTIVITY_ENTRANCE = 26; // THEATRE_HOT_TOPIC = 27; // RELATED_RECOMMEND = 28; // PAY_BAR = 29; // BANNER = 30; // AUDIO = 31; // AGG_CARD = 32; // SINGLE_EP = 33; // LIKE_COMMENT = 34; // ATTENTION_RECOMMEND = 35; // COVENANTER = 36; } // message MultiViewEp { // int64 ep_id = 1; } // message NewEp { // int32 id = 1; // string title = 2; // string desc = 3; // int32 is_new = 4; // string more = 5; // string cover = 6; // string index_show = 7; } // enum OccupationType { // STAFF = 0; // CAST = 1; } // message OfficialVerify { // int32 type = 1; // string desc = 2; } // message OgvIntroduction { // string followers = 1; // string score = 2; // StatInfo play_data = 3; } // message OgvLiveReserve { // int64 reserve_id = 1; // string title = 2; // string icon = 3; // string night_icon = 4; // string click_button = 5; // string link = 6; // int32 follow_video_is_reserve_live = 7; // string bg_color = 8; // string night_bg_color = 9; // string text_color = 10; // string night_text_color = 11; // string bt_bg_color = 12; // string bt_frame_color = 13; // string night_bt_bg_color = 14; // string night_bt_frame_color = 15; // int32 active_type = 16; // int32 reserve_status = 17; // string bt_text_color = 18; // string night_bt_text_color = 19; // map report = 20; } // message OgvSeasons { // string title = 1; // repeated SerialSeason serial_season = 2; // SerialSeasonCoverStyle style = 3; } // message OgvTitle { // string title = 1; // BadgeInfo badge_info = 2; // int32 is_show_btn_animation = 3; // int32 follow_video_is_reserve_live = 4; // int64 reserve_id = 5; // TitleDeliveryButton title_delivery_button = 6; } // message Owner { bilibili.dagw.component.avatar.v1.AvatarItem avatar = 1; // string url = 2; // string title = 3; // string fans = 4; // string arc_count = 5; // int32 attention = 6; // int32 attention_relation = 7; // string pub_location = 8; // Vip vip = 9; // string title_url = 10; // string face = 11; // int64 mid = 12; // OfficialVerify official_verify = 13; // Live live = 14; // int64 fans_num = 15; // repeated int64 assists = 16; } // message Page { // int64 cid = 1; // string part = 2; // int64 duration = 3; // string desc = 4; // Dimension dimension = 5; // string dl_title = 6; // string dl_subtitle = 7; } // message Pendant { // int32 pid = 1; // string name = 2; // string image = 3; } // message PlayerAnimation { // string player_icon = 1; // string player_triple_icon = 2; } // message PointActivity { // string tip = 1; // string content = 2; // string link = 3; } // message PowerIconStyle { // string icon_url = 1; // string icon_night_url = 2; // int64 icon_width = 3; // int64 icon_height = 4; } // message Rank { // string icon = 1; // string icon_night = 2; // string text = 3; } // message RankInfo { // string icon_url_night = 1; // string icon_url_day = 2; // string bkg_night_color = 3; // string bkg_day_color = 4; // string font_night_color = 5; // string font_day_color = 6; // string rank_content = 7; // string rank_link = 8; } // message Rating { // string score = 1; // int32 count = 2; } // 视频详情下方推荐卡子类型: 普通视频 message RelateAVCard { // int64 duration = 1; // int64 cid = 2; // Dimension dimension = 3; // Stat stat = 4; // string jump_url = 5; // bool show_up_name = 6; // BadgeInfo rcmd_reason = 7; } // 视频详情下方推荐卡子类型: 番剧(小卡?) message RelateBangumiAvCard { // BadgeInfo badge = 1; // Stat stat = 2; // Rating rating = 3; } // 视频详情下方推荐卡子类型: 番剧(大卡?) message RelateBangumiCard { // int32 season_id = 1; // int32 season_type = 2; // NewEp new_ep = 3; // Stat stat = 4; // Rating rating = 5; // string rcmd_reason = 6; // BadgeInfo badge_info = 7; // string goto_type = 8; // map report = 9; } // 视频详情下方推荐卡子类型: 番剧集? message RelateBangumiResourceCard { // int32 type = 1; // string scover = 2; // int32 re_type = 3; // string re_value = 4; // string corner = 5; // int32 card = 6; // string siz = 7; // int32 position = 8; // string rcmd_reason = 9; // string label = 10; // map report = 11; // string goto_type = 12; } // 视频详情下方推荐卡子类型: UGC 番剧? message RelateBangumiUgcCard { // BadgeInfo badge = 1; // Stat stat = 2; // Rating rating = 3; } // 视频详情下方推荐卡 message RelateCard { // RelateCardType relate_card_type = 1; // oneof card { // RelateAVCard av = 2; // RelateBangumiCard bangumi = 3; // RelateBangumiResourceCard resource = 4; // RelateGameCard game = 5; // RelateCMCard cm = 6; // RelateLiveCard live = 7; // RelateBangumiAvCard bangumi_av = 8; // RelatedAICard ai_card = 9; // RelateBangumiUgcCard bangumi_ugc = 13; // RelateSpecial special = 14; } // RelateThreePoint three_point = 10; // google.protobuf.Any cm_stock = 11; // CardBasicInfo basic_info = 12; } // 视频详情下方推荐卡子类型 enum RelateCardType { // CARD_TYPE_UNKNOWN = 0; // AV = 1; // BANGUMI = 2; // RESOURCE = 3; // GAME = 4; // CM = 5; // LIVE = 6; // AI_RECOMMEND = 7; // BANGUMI_AV = 8; // BANGUMI_UGC = 9; // SPECIAL = 10; } // 视频详情下方推荐卡子类型: 广告推广 message RelateCMCard { // int64 aid = 1; // google.protobuf.Any source_content = 2; // int64 duration = 3; // Stat stat = 4; } // 视频详情下方推荐配置 message RelateConfig { // int64 valid_show_m = 1; // int64 valid_show_n = 2; // bilibili.pagination.Pagination pagination = 3; // bool can_load_more = 4; } // 视频详情下方推荐卡子类型: AI 推荐? message RelatedAICard { // int64 aid = 1; // int64 duration = 2; // Staff up_info = 3; // Stat stat = 4; // map report = 5; // string goto_type = 6; } // 视频详情下方推荐卡子类型: 点击不喜欢后占位卡片 message RelateDislike { // string title = 1; // string sub_title = 2; // string closed_sub_title = 3; // string paste_text = 4; // string closed_paste_text = 5; // repeated DislikeReasons dislike_reason = 6; // string toast = 7; // string closed_toast = 8; } // 视频详情下方推荐卡子类型: 游戏推广 message RelateGameCard { // int64 reserve_status = 1; // string reserve_status_text = 2; // string reserve = 3; // float rating = 4; // string tag_name = 5; // RankInfo rank_info = 6; // Button pack_info = 7; // Button notice = 8; // PowerIconStyle power_icon_style = 9; // string game_rcmd_reason = 10; // WikiInfo wiki_info = 11; // BadgeInfo badge = 12; } // message RelateItem { // string url = 1; // string cover = 2; // bool use_default_browser = 3; } // 视频详情下方推荐卡子类型: 直播 message RelateLiveCard { // int64 icon_type = 1; // string area_name = 2; // int64 watched_show = 3; // int64 live_status = 4; } // 视频下方推荐区 message Relates { // repeated RelateCard cards = 1; // RelateConfig config = 2; } // 视频详情下方推荐卡子类型: 其他特殊 message RelateSpecial { // BadgeInfo badge = 1; // BadgeInfo rcmd_reason = 2; } // 视频详情下方推荐卡右上角三点的内容 message RelateThreePoint { // RelateDislike dislike = 1; // RelateDislike feedback = 2; // bool watch_later = 3; // string dislike_report_data = 4; } // enum ReserveBizType { // BizTypeNone = 0; // BizTypeReserveActivity = 1; // BizTypeFavSeason = 2; } // message ReserveButton { // bool status = 1; // string text = 3; // string selected_text = 4; // ReserveBizType order_type = 7; // oneof order_param { // BizReserveActivityParam reserve = 8; // BizFavParam fav = 9; } } // message Rights { // int32 allow_download = 1; // int32 allow_review = 2; // int32 can_watch = 3; // string resource = 4; // int32 allow_dm = 5; // int32 allow_demand = 6; // 区域限制 int32 area_limit = 7; } // message SeasonHead { // string title = 1; // string intro = 2; // StatInfo vt = 3; // StatInfo danmaku = 4; } // message SeasonShow { // string button_text = 1; // string join_text = 2; // string rule_text = 3; // string checkin_text = 4; // string checkin_prompt = 5; } enum SeasonType { // Unknown = 0; // Base = 1; // Good = 2; } // message SectionData { // int32 id = 1; // int32 section_id = 2; // string title = 3; // int32 can_ord_desc = 4; // string more = 5; // repeated int32 episode_ids = 6; // repeated ViewEpisode episodes = 7; // string split_text = 8; // Style module_style = 9; // string more_bottom_desc = 10; // repeated SerialSeason seasons = 11; // Button more_left = 12; // int32 type = 13; // map report = 14; } // message SerialSeason { // int32 season_id = 1; // string title = 2; // string season_title = 3; // int32 is_new = 4; // string cover = 5; // string badge = 6; // int32 badge_type = 7; // BadgeInfo badge_info = 8; // string link = 9; // string resource = 10; // NewEp new_ep = 11; } enum SerialSeasonCoverStyle { // TITLE = 0; // PICTURE = 1; } // message SkipRange { // int32 start = 1; // int32 end = 2; } // message Sponsor { // int64 total = 1; // int64 week = 2; // repeated SponsorRank rank_list = 3; // Mine mine = 4; // PointActivity point_activity = 5; // repeated Pendant pendants = 6; // repeated Threshold threshold = 7; } // message SponsorRank { // int64 uid = 1; // string msg = 2; // string uname = 3; // string face = 4; // Vip vip = 5; } // message Staff { // int64 mid = 1; // int32 attention = 2; // string title = 3; // string name = 4; // string face = 5; // OfficialVerify official = 6; // Vip vip = 7; // int32 label_style = 8; // string fans = 9; } // message Staffs { // repeated Staff staff = 1; // string title = 2; } // message Stat { // 视频观看时长 StatInfo vt = 1; // 弹幕 StatInfo danmaku = 2; // 回复数 int64 reply = 3; // 收藏数 int64 fav = 4; // 硬币数 int64 coin = 5; // 分享数 int64 share = 6; // 点赞数 int64 like = 7; // 关注数 int64 follow = 8; } // message StatInfo { // int64 value = 1; // string text = 2; // string pure_text = 3; // string icon = 4; } // message Style { // int32 line = 1; // int32 hidden = 2; // repeated string show_pages = 3; } // message Tag { // int64 tag_id = 1; // string name = 2; // string uri = 3; // string tag_type = 4; } // message TheatreHotTopic { // int64 theatre_id = 1; // int64 theatre_set_id = 2; // string theatre_title = 3; // string background_image_url = 4; // string theatre_url = 5; // int64 hot_topic_id = 6; // Original one is hottopicsetid, here renamed int64 hot_topic_set_id = 7; // Original one is hottopictitle, here renamed string hot_topic_title = 8; // string hot_topic_url = 9; // int32 is_subscribe = 10; // map report = 11; } // message Threshold { // int32 bp = 1; // int32 days = 2; // string days_text = 3; } // message TitleDeliveryButton { // string icon = 1; // string title = 2; // string link = 3; // map report = 4; } // message UgcEpisode { // int64 id = 1; // int64 aid = 2; // int64 cid = 3; // string title = 4; // string cover = 5; // string cover_right_text = 6; // Page page = 7; // StatInfo vt = 8; // StatInfo danmaku = 9; } // message UgcIntroduction { // repeated Tag tags = 1; // Rating rating = 2; // Rank rank = 3; // repeated ViewMaterial bgm = 4; // repeated ViewMaterial sticker = 5; // repeated ViewMaterial video_source = 6; // int64 pubdate = 7; // repeated DescV2 desc = 8; } // message UgcSeasonActivity { // int32 type = 1; // int64 oid = 2; // int64 activity_id = 3; // string title = 4; // string intro = 5; // int32 day_count = 6; // int32 user_count = 7; // int64 join_deadline = 8; // int64 activity_deadline = 9; // int32 checkin_view_time = 10; // bool new_activity = 11; // UserActivity user_activity = 12; // SeasonShow season_show = 13; } // message UgcSeasons { // int64 id = 1; // string title = 2; // string cover = 3; // string supernatant_title = 4; // repeated UgcSection section = 5; // string union_title = 6; // SeasonHead head = 7; // int64 ep_count = 8; // int32 season_type = 9; // UgcSeasonActivity activity = 10; // repeated string season_ability = 11; // string season_title = 12; } // message UgcSection { // int64 id = 1; // string title = 2; // int64 type = 3; // repeated UgcEpisode episodes = 4; } // message UpLikeImg { // string pre_img = 1; // string suc_img = 2; // string content = 3; // int64 type = 4; } // message User { // int64 mid = 1; // string name = 2; // string face = 3; // int64 follower = 4; } // message UserActivity { // int32 user_state = 1; // int64 last_checkin_date = 2; // int32 checkin_today = 3; // int32 user_day_count = 4; // int32 user_view_time = 5; // string portrait = 6; } // message UserList { // repeated User list = 1; // string title = 2; } // message UserStatus { // int32 show = 1; // int32 follow = 2; } // message ViewEpisode { // int64 ep_id = 1; // string badge = 2; // int32 badge_type = 3; // BadgeInfo badge_info = 4; // int32 duration = 5; // int32 status = 6; // string cover = 7; // int64 aid = 8; // string title = 9; // string movie_title = 10; // string subtitle = 11; // string long_title = 12; // string toast_title = 13; // int64 cid = 14; // string from = 15; // string share_url = 16; // string share_copy = 17; // string short_link = 18; // string vid = 19; // string release_date = 20; // Dimension dimension = 21; // Rights rights = 22; // Interaction interaction = 23; // string bvid = 24; // int32 archive_attr = 25; // string link = 26; // string link_type = 27; // string bmid = 28; // int64 pub_time = 29; // int32 pv = 30; // int32 ep_index = 31; // int32 section_index = 32; // repeated Staff up_infos = 33; // Staff up_info = 34; // string dialog_type = 35; // string toast_type = 36; // repeated MultiViewEp multi_view_eps = 37; // bool is_sub_view = 38; // bool is_view_hide = 39; // string jump_link = 40; // Stat stat_for_unity = 41; // map report = 42; } // message ViewMaterial { // int64 oid = 1; // int64 mid = 2; // string title = 3; // string author = 4; // string jump_url = 5; } // message Vip { // int32 type = 1; // int32 vip_status = 2; // int32 theme_type = 3; // VipLabel label = 4; // int32 is_vip = 5; } // message VipLabel { // string path = 1; // string text = 2; // string label_theme = 3; } // message WikiInfo { // string wiki_label = 1; // string wiki_url = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/viewunite/pgcanymodel.proto ================================================ syntax = "proto3"; package bilibili.app.viewunite.pgcanymodel; option java_multiple_files = true; import "bilibili/app/viewunite/common.proto"; // message Earphone { // string product_model = 1; // string like_toast_text = 2; // string switch_toast_text = 3; // string like_toast_voice = 4; } // message EarphoneConf { // repeated Earphone sp_phones = 1; } // message MultiViewInfo { // bool is_multi_view_season = 1; // string changing_dance = 2; } // message OgvData { // int32 media_id = 1; // int64 season_id = 2; // int32 season_type = 3; // int32 show_season_type = 4; // Rights rights = 5; // UserStatus user_status = 6; // int64 aid = 7; // Stat stat = 8; // int32 mode = 9; // Publish publish = 10; // PlayStrategy play_strategy = 11; // MultiViewInfo multi_view_info = 12; // OgvSwitch ogv_switch = 13; // int32 total_ep = 14; // bilibili.app.viewunite.common.NewEp new_ep = 15; // Reserve reserve = 16; // int32 status = 17; // repeated PlayFloatLayerActivity activity_float_layer = 18; // EarphoneConf earphone_conf = 19; // string cover = 20; // string square_cover = 21; // string share_url = 22; // string short_link = 23; // string title = 24; // string horizontal_cover169 = 25; // string horizontal_cover1610 = 26; // int32 has_can_play_ep = 27; } // message OgvSwitch { // int32 reduce_short_title_spacing = 1; // int32 merge_position_section_for_cinema = 2; // int32 merge_preview_section = 3; // int32 enable_show_vt_info = 4; } // 播放器浮层广告(?) message PlayFloatLayerActivity { // int32 id = 1; // string title = 2; // int32 type = 3; // int32 ad_badge_type = 4; // string link = 5; // string pic_url = 6; // string pic_anima_url = 7; // bilibili.app.viewunite.common.BadgeInfo badge = 8; // int64 show_rate_time = 9; } // message PlayStrategy { // repeated string strategies = 1; // int32 recommend_show_strategy = 2; // string auto_play_toast = 3; } // message Publish { // string pub_time = 1; // string pub_time_show = 2; // int32 is_started = 3; // int32 is_finish = 4; // int32 weekday = 5; // string release_date_show = 6; // string time_length_show = 7; // int32 unknow_pub_date = 8; // string update_info_desc = 9; } // message Reserve { // repeated bilibili.app.viewunite.common.ViewEpisode episodes = 1; // string tip = 2; } // 权限相关信息 message Rights { // int32 allow_download = 1; // int32 allow_review = 2; // int32 can_watch = 3; // int32 is_cover_show = 4; // string copyright = 5; // string copyright_name = 6; // int32 allow_bp = 7; // int32 area_limit = 8; // int32 is_preview = 9; // int32 ban_area_show = 10; // int32 watch_platform = 11; // int32 allow_bp_rank = 12; // string resource = 13; // int32 forbid_pre = 14; // int32 only_vip_download = 15; // int32 new_allow_download = 16; } // message Stat { // string followers = 1; // bilibili.app.viewunite.common.StatInfo play_data = 2; } // message UserStatus { // int32 show = 1; // int32 follow = 2; // int32 follow_status = 3; // int32 pay = 4; // int32 sponsor = 5; // int32 vip = 6; // vip 是否被冻结 int32 vip_frozen = 7; // WatchProgress watch_progress = 8; } // message ViewPgcAny { // OgvData ogv_data = 1; // map all_up_info = 2; } // message WatchProgress { // int64 last_ep_id = 1; // string last_ep_index = 2; // int64 last_time = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/viewunite/ugcanymodel.proto ================================================ syntax = "proto3"; package bilibili.app.viewunite.ugcanymodel; option java_multiple_files = true; import "bilibili/app/viewunite/common.proto"; // message Dislike { // string title = 1; // string subtitle = 2; // repeated DislikeReason reasons = 3; } // message DislikeReason { // int64 id = 1; // int64 mid = 2; // int32 rid = 3; // int64 tag_id = 4; // string name = 5; } // message ElecRank { // repeated ElecRankItem list = 1; // int64 count = 2; // string text = 3; } // message ElecRankItem { // string avatar = 1; // string nickname = 2; // string message = 3; // int64 mid = 4; } // message Premiere { // PremiereState premiere_state = 1; // int64 start_time = 2; // int64 service_time = 3; // int64 room_id = 4; } // message PremiereReserve { // int64 reserve_id = 1; // int64 count = 2; // bool is_follow = 3; } // message PremiereResource { // Premiere premiere = 1; // PremiereReserve reserve = 2; // PremiereText text = 3; } enum PremiereState { // premiere_none = 0; // premiere_before = 1; // premiere_in = 2; // premiere_after = 3; } // message PremiereText { // string title = 1; // string subtitle = 2; // string online_text = 3; // string online_icon = 4; // string online_icon_dark = 5; // string intro_title = 6; // string intro_icon = 7; // string guidance_pulldown = 8; // string guidance_entry = 9; // string intro_icon_night = 10; } // message ViewUgcAny { // PremiereResource premiere = 1; // Dislike dislike = 2; // string short_link = 3; // string share_subtitle = 4; // repeated bilibili.app.viewunite.common.Page pages = 5; // ElecRank elec_rank = 6; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/viewunite/v1/viewunite.proto ================================================ syntax = "proto3"; package bilibili.app.viewunite.v1; option java_multiple_files = true; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/app/viewunite/common.proto"; import "bilibili/pagination/pagination.proto"; import "google/protobuf/any.proto"; // 统一视频信息接口 (7.41.0+) service View { // rpc ArcRefresh(ArcRefreshReq) returns (ArcRefreshReply); // 视频详情页下方推荐 rpc RelatesFeed(RelatesFeedReq) returns (RelatesFeedReply); // rpc View(ViewReq) returns (ViewReply); // rpc ViewProgress(ViewProgressReq) returns (ViewProgressReply); } // message ActivityResource { // string dark_text_color = 1; // string divider_color = 2; // string bg_color = 3; // string selected_bg_color = 4; // string text_color = 5; // string light_text_color = 6; } // 业务信息 message Arc { // int64 aid = 1; // int64 cid = 2; // int64 duration = 3; // bilibili.app.viewunite.common.Stat stat = 4; // string bvid = 5; // int32 copyright = 6; // Rights right = 7; // string cover = 8; // int64 type_id = 9; // string title = 10; } // message ArcRefreshReply { // bilibili.app.viewunite.common.Stat stat = 1; // SimpleReqUser req_user = 2; // SimpleArc arc = 3; // Online online = 4; // LikeConfig like_config = 5; } // message ArcRefreshReq { // int64 aid = 1; // string bvid = 2; } // message AttentionCard { // repeated ShowTime show_time = 1; } // message BizFollowVideoParam { // int64 season_id = 1; } // message BizJumpLinkParam { // string url = 1; } // message BizReserveActivityParam { // int64 activity_id = 1; // string from = 2; // string type = 3; // int64 oid = 4; // int64 reserve_id = 5; } // message BizReserveGameParam { // int64 game_id = 1; } enum BizType { // BizTypeNone = 0; // BizTypeFollowVideo = 1; // BizTypeReserveActivity = 2; // BizTypeJumpLink = 3; // BizTypeFavSeason = 4; // BizTypeReserveGame = 5; } // message Button { // string title = 1; // string uri = 2; // string icon = 3; // JumpShowType jump_show_type = 4; } // message ChargingPlus { // bool pass = 1; // repeated PlayToast play_toast = 2; } // message Chronos { // string md5 = 1; // string file = 2; // string sign = 3; } // message ChronosParam { // string engine_version = 1; // string message_protocol = 2; // string service_key = 3; } // 推广信息 message CM { // google.protobuf.Any cm_under_player = 1; // google.protobuf.Any ads_control = 2; // repeated google.protobuf.Any source_content = 3; } // message CommandDm { // int64 id = 1; // int64 oid = 2; // int64 mid = 3; // string command = 4; // string content = 5; // int32 progress = 6; // string ctime = 7; // string mtime = 8; // string extra = 9; // string idstr = 10; } // 播放器配置 message Config { // Online online = 1; // PlayerIcon player_icon = 2; // StoryEntrance story_entrance = 3; } // 视频播放时弹出的卡片 message ContractCard { // 在第几秒弹出 float display_progress = 1; // int64 display_accuracy = 2; // 弹出后停留的时间 int64 display_duration = 3; // 展示方式, 暂未知对应关系 int32 show_mode = 4; // 页面类型, 暂未知对应关系 int32 page_type = 5; // UpperInfos upper = 6; // int32 is_follow_display = 7; // 卡片的文字说明信息 ContractText text = 8; // int64 follow_display_end_duration = 9; // int32 is_play_display = 10; // int32 is_interact_display = 11; } // 视频播放时弹出的卡片的文字说明信息 message ContractText { // string title = 1; // string subtitle = 2; // string inline_title = 3; } // message Control { // bool limit = 1; } // message DmResource { // repeated CommandDm command_dms = 1; // AttentionCard attention = 2; // repeated OperationCard cards = 3; } enum ECode { // CODE_DEFAULT = 0; // CODE_404 = 1; // 青少年限制 CODE_TEENAGER = 78301; } // message ECodeConfig { // string redirect_url = 1; } // message IconData { // string meta_json = 1; // string sprits_img = 2; } // 视频介绍 Tab message IntroductionTab { // string title = 1; // repeated bilibili.app.viewunite.common.Module modules = 2; } enum JumpShowType { // JST_DEFAULT = 0; // JST_FULLSCREEN = 1; // JST_HALFSCREEN = 2; } // message LikeConfig { bilibili.app.viewunite.common.UpLikeImg triple_like = 1; // string like_animation = 2; } // 素材详情 message Material { // string icon = 1; // string text = 2; // string url = 3; // MaterialBizType type = 4; // string param = 5; // string static_icon = 6; // string bg_color = 7; // string bg_pic = 8; // int32 jump_type = 9; // PageType page_type = 10; // bool need_login = 11; } // 素材类型 enum MaterialBizType { // NONE = 0; // ACTIVITY = 1; // BGM = 2; // EFFECT = 3; // SHOOT_SAME = 4; // SHOOT_TOGETHER = 5; // ACTIVITY_ICON = 6; // NEW_BGM = 7; } // 素材来源 enum MaterialSource { // DEFAULT = 0; // 必剪素材 BIJIAN = 1; } // message Online { // bool online_show = 1; } // message OperationCard { // int64 id = 1; // int32 from = 2; // int32 to = 3; // bool status = 4; // BizType biz_type = 5; // OperationCardContent content = 6; // oneof param { // BizFollowVideoParam follow = 7; // BizReserveActivityParam reserve = 8; // BizJumpLinkParam jump = 9; // BizReserveGameParam game = 10; } } // message OperationCardContent { // string title = 1; // string subtitle = 2; // string icon = 3; // string button_title = 4; // string button_selected_title = 5; // bool show_selected = 6; } // enum PageCategory { // COMMON_PAGE = 0; // ACTIVITY_PAGE = 1; } // message PageControl { Control toast_show = 1; Control material_show = 2; Control up_show = 3; } // 页面类型 enum PageType { // H5页面(Webview) H5 = 0; // 原生页面(native) NA = 1; } // message PlayerIcon { // string url1 = 1; // string hash1 = 2; // string url2 = 3; // string hash2 = 4; // string drag_left_png = 5; // string middle_png = 6; // string drag_right_png = 7; // IconData drag_data = 8; // IconData nodrag_data = 9; } // message PlayToast { // PlayToastEnum business = 1; // string icon_url = 2; // string text = 3; } enum PlayToastEnum { // PLAYTOAST_UNKNOWN = 0; // PLAYTOAST_CHARGINGPLUS = 1; } // message PointMaterial { // string url = 1; // MaterialSource material_source = 2; } // message Relate { // int64 device_type = 1; // bilibili.pagination.Pagination pagination = 2; } // 视频详情页下方推荐 Reply message RelatesFeedReply { // repeated bilibili.app.viewunite.common.RelateCard relates = 1; // bilibili.pagination.Pagination pagination = 2; } // 视频详情页下方推荐 Req message RelatesFeedReq { // int64 aid = 1; // string bvid = 2; // string from = 3; // string spmid = 4; // string from_spmid = 5; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 6; // bilibili.pagination.Pagination pagination = 7; // string session_id = 8; // int64 auto_play = 9; // string from_track_id = 10; } // message ReplyStyle { // string badge_url = 1; // string badge_text = 2; // int64 badge_type = 3; } // message ReplyTab { // ReplyStyle reply_style = 1; // string title = 2; // TabControl control = 3; } // message ReqUser { // int32 favorite = 1; // int32 like = 2; // int32 coin = 3; // int32 fav_season = 4; // int32 follow = 5; // int32 dislike = 6; // 头像旁充电按钮 Button elec_plus_btn = 7; // ChargingPlus charging_plus = 8; } // message Rights { // bool only_vip_download = 1; // bool no_reprint = 2; // bool download = 3; } // message ShowTime { // int32 start_time = 1; // int32 end_time = 2; // double pos_x = 3; // double pos_y = 4; } // message SimpleArc { // int32 copyright = 1; } // message SimpleReqUser { // int32 favorite = 1; // int32 like = 2; // int32 coin = 3; } // message StoryEntrance { // bool arc_play_story = 1; // string story_icon = 2; // bool arc_landscape_story = 3; // string landscape_icon = 4; // bool play_story = 5; } // message Tab { // repeated TabModule tab_module = 1; // string tab_bg = 2; // TabControl danmaku_entrance = 3; } // 评论区/弹幕 Tab 控制 message TabControl { // bool limit = 1; // bool disable = 2; // string disable_click_tip = 3; } // message TabModule { // TabType tab_type = 1; // oneof tab { // IntroductionTab introduction = 2; // ReplyTab reply = 3; // bilibili.app.viewunite.common.ActivityTab activity_tab = 4; } } enum TabType { // TAB_NONE = 0; // 详情 Tab TAB_INTRODUCTION = 1; // 评论区 Tab TAB_REPLY = 2; // OGV 活动信息 Tab TAB_OGV_ACTIVITY = 3; } // enum UnionType { // UGC = 0; // OGV = 1; } // UP主信息(可是Upper这个... 程序员英文不过关吧? ) message UpperInfos { // 粉丝数 uint64 fans_count = 1; // 过去半年内的稿件数 uint64 arc_count_last_half_year = 2; // int64 first_up_dates = 3; // UP稿件总播放数 uint64 total_play_count = 4; } // message VideoGuide { // repeated Material material = 1; // VideoViewPoint video_point = 2; // ContractCard contract_card = 3; } // message VideoPoint { // int32 type = 1; // int64 from = 2; // int64 to = 3; // string content = 4; // string cover = 5; // string logo_url = 6; } // message VideoShot { // string pv_data = 1; // int32 img_x_len = 2; // int32 imd_x_size = 3; // int32 img_y_len = 4; // int32 img_y_size = 5; // repeated string image = 6; } // message VideoViewPoint { // repeated VideoPoint points = 1; // PointMaterial point_material = 2; // bool point_permanent = 3; } // message ViewBase { // UnionType union_type = 1; // PageType page_type = 2; // PageControl control = 3; // ActivityResource activity_resource = 4; // Config config = 5; } // message ViewProgressReply { // VideoGuide video_guide = 1; // Chronos chronos = 2; // VideoShot arc_shot = 3; // DmResource dm = 4; } // message ViewProgressReq { // uint64 aid = 1; // uint64 cid = 2; // uint64 up_mid = 3; // ChronosParam chronos_param = 4; // UnionType type = 5; } // message ViewReply { // ViewBase view_base = 1; // Arc arc = 2; // ReqUser req_user = 3; // bilibili.app.viewunite.common.Owner owner = 4; // Tab tab = 5; // google.protobuf.Any supplement = 6; // CM cm = 7; // ECode ecode = 8; // ECodeConfig ecode_config = 9; // map report = 10; } // message ViewReq { // uint64 aid = 1; // string bvid = 2; // string from = 3; // string spmid = 4; // string from_spmid = 5; // string session_id = 6; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 7; // string track_id = 8; // map extra_content = 9; // string play_mode = 10; // Relate relate = 11; // string biz_extra = 12; // string ad_extra = 13; } ================================================ FILE: bili-api/grpc/proto/bilibili/app/wall/v1/wall.proto ================================================ syntax = "proto3"; package bilibili.app.wall.v1; option java_multiple_files = true; // 免流规则 service Wall { // 获取免流规则信息 rpc RuleInfo (RuleRequest) returns (RulesReply); } // 免流规则信息 message RuleInfo { // 是否支持免流 bool tf = 1; // 操作模式 // break:无 replace:替换 proxy:代理 string m = 2; // 操作参数 string a = 3; // 匹配目标正则 string p = 4; // repeated string a_backup = 5; } // 获取免流规则信息-请求 message RuleRequest {} // 免流规则信息组 message RulesInfo { // 免流规则信息 repeated RuleInfo rulesInfo = 1; } // 获取免流规则信息-响应 message RulesReply { // 各ISP的免流规则信息组 // ISP如: cu ct cm map rulesInfo = 1; // string hash_value = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/editor/notify.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.editor; option java_multiple_files = true; import "google/protobuf/empty.proto"; // service OperationNotify { // rpc OperationNotify(google.protobuf.Empty) returns (stream Notify); } message Notify { // 消息唯一标示 int64 msg_id = 1; // 消息类型 int32 msg_type = 2; // 接收方uid int64 receiver_uid = 3; //接收方类型 int32 receiver_type = 4; // 故事的版本 int64 story_version = 5; // 操作结果的hash值 int64 op_hash = 6; // 操作产生用户的uid int64 op_sender = 7; // patch内容 string op_content = 8; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/esports/notify.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.esports; option java_multiple_files = true; message Notify { // cid int64 cid = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/fission/notify.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.fission; option java_multiple_files = true; import "google/protobuf/empty.proto"; // service Fission { // rpc GameNotify(google.protobuf.Empty) returns (stream GameNotifyReply); } message GameNotifyReply { // 类型字段 uint32 type = 1; // 数据字段 string data = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/im/notify.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.im; option java_multiple_files = true; import "google/protobuf/empty.proto"; // service Notify { // rpc WatchNotify(google.protobuf.Empty) returns (stream NotifyRsp); } // enum PLType { // EN_PAYLOAD_NORMAL = 0; // EN_PAYLOAD_BASE64 = 1; } // enum CmdId { // 非法cmd EN_CMD_ID_INVALID = 0; // 服务端主动发起 EN_CMD_ID_MSG_NOTIFY = 1; // EN_CMD_ID_KICK_OUT = 2; } // message NotifyRsp { // uint64 uid = 1; // 命令id uint64 cmd = 2; // bytes payload = 3; // PLType payload_type = 4; } // message Msg { // 发送方uid uint64 sender_uid = 1; // 接收方类型 int32 receiver_type = 2; // 接收方id uint64 receiver_id = 3; // 客户端的序列id 用于服务端去重 uint64 cli_msg_id = 4; // 消息类型 int32 msg_type = 5; // 消息内容 string content = 6; // 服务端的序列号 uint64 msg_seqno = 7; // 消息发送时间(服务端时间) uint64 timestamp = 8; // at用户列表 repeated uint64 at_uids = 9; // 多人消息 repeated uint64 recver_ids = 10; // 消息唯一标示 uint64 msg_key = 11; // 消息状态 uint32 msg_status = 12; // 是否为系统撤销 bool sys_cancel = 13; // 是否是多聊消息 目前群通知管理员的部分通知属于该类消息 uint32 is_multi_chat = 14; // 表示撤回的消息的session_seqno 用以后续的比较 实现未读数的正确显示 uint64 withdraw_seqno = 15; // 通知码 string notify_code = 16; // 消息来源 uint32 msg_source = 17; } // message NotifyInfo { // uint32 msg_type = 1; // uint64 talker_id = 2; // uint32 session_type = 3; } // message ReqServerNotify { // 最新序列号 uint64 lastest_seqno = 1; // 即时消息 该类消息主要用于系统通知 当客户端sync msg时 不会sync到此类消息 Msg instant_msg = 2; // NotifyInfo notify_info = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/main/dm.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.main; option java_multiple_files = true; // 实时弹幕事件 message DanmukuEvent { // 弹幕列表 repeated DanmakuElem elems = 1; } // 弹幕条目 message DanmakuElem { // 弹幕dmid int64 id = 1; // 弹幕出现位置(单位为ms) int32 progress = 2; // 弹幕类型 int32 mode = 3; // 弹幕字号 int32 fontsize = 4; // 弹幕颜色 uint32 color = 5; // 发送着mid hash string mid_hash = 6; // 弹幕正文 string content = 7; // 发送时间 int64 ctime = 8; // 弹幕动作 string action = 9; // 弹幕池 int32 pool = 10; // 弹幕id str string id_str = 11; } // 互动弹幕 message CommandDm { // 弹幕id int64 id = 1; // 对象视频cid int64 oid = 2; // 发送者mid int64 mid = 3; // int32 type = 4; // 互动弹幕指令 string command = 5; // 互动弹幕正文 string content = 6; // 弹幕状态 int32 state = 7; // 出现时间 int32 progress = 8; // 创建时间 string ctime = 9; // 发布时间 string mtime = 10; // 扩展json数据 string extra = 11; // 弹幕id str类型 string idStr = 12; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/main/native.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.main; option java_multiple_files = true; import "google/protobuf/empty.proto"; // service NativePage { // rpc WatchNotify(google.protobuf.Empty) returns (stream NativePageEvent); } // message NativePageEvent { // Native页ID int64 PageID = 1; // repeated EventItem Items = 2; } // message EventItem { // 组件标识 int64 ItemID = 1; // 组件类型 string Type = 2; // 进度条数值 int64 Num = 3; // 进度条展示数值 string DisplayNum = 4; // h5的组件标识 string WebKey = 5; // 活动统计维度 // 0:用户维度 1:规则维度 int64 dimension = 6; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/main/resource.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.main; import "google/protobuf/empty.proto"; option java_multiple_files = true; // service Resource { // rpc TopActivity(google.protobuf.Empty) returns (stream TopActivityReply); } // message TopActivityReply { // 当前生效的资源 TopOnline online = 1; // 对online内容进行hash和上次结果一样则不重新加载 string hash = 2; } // 当前生效的资源 message TopOnline { // 活动类型 // 1:七日活动 2:后台配置 int32 type = 1; // 图标 string icon = 2; // 跳转链接 string uri = 3; // 资源状态标识(后台配置) string unique_id = 4; // 动画资源 Animate animate = 5; // 红点 RedDot red_dot = 6; // 活动名称 string name = 7; // 轮询间隔 单位秒 int64 interval = 8; } // 动画资源 message Animate { // 动效结束展示icon string icon = 1; // 7日活动动画 string json = 2; // s10活动svg动画 string svg = 3; // 循环次数(默认0不返回 表示无限循环) int32 loop = 4; } // 红点 message RedDot { // 红点类型 // 1:纯红点 2:数字红点 int32 type = 1; // 如果是数字红点 显示的数字 int32 number = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/main/search.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.main; option java_multiple_files = true; import "google/protobuf/empty.proto"; import "bilibili/app/dynamic/v2/dynamic.proto"; service Search { rpc ChatResultPush (google.protobuf.Empty) returns (stream ChatResult); } // message Bubble { repeated bilibili.app.dynamic.v2.Paragraph paragraphs = 1; } // message ChatResult { // int32 code = 1; // string session_id = 2; // repeated Bubble bubble = 3; // string rewrite_word = 4; // string title = 5; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/note/sync.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.note; option java_multiple_files = true; // message Sync { // 笔记id int64 note_id = 1; // 唯一标示 string hash = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/ogv/freya.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.ogv; option java_multiple_files = true; // 播放状态 enum PlayStatus { // 暂停 Pause = 0; // 播放 Play = 1; // 终止 End = 2; } // 房间类型 enum RoomType { // 私密 Private = 0; // 公开 Open = 1; } // 信息通知发送领域 enum MessageDomain { // 默认 DefaultDomain = 0; // 房间用户 RoomMid = 1; // 系统通知 SystemInfo = 2; } // 通知信息类型 enum MessageType { // 默认 DefaultType = 0; // 房间用户 ChatMessage = 1; // 系统通知 SystemMessage = 2; } // 触发通知类型 enum TriggerType { // 默认 DefaultTrigger = 0; // 关注、取消关注 Relation = 1; } // 房间人员变更事件 message RoomMemberChangeEvent { // 房间id int64 room_id = 1; // 房主id int64 owner_id = 2; // 房间成员列表 repeated UserInfoProto members = 3; // 提示信息 MessageProto message = 4; } // 播放进度同步事件 message ProgressSyncEvent { // 房间id int64 room_id = 1; // 播放中的season_id int64 season_id = 2; // 播放中的episode_id int64 episode_id = 3; // 播放状态 PlayStatus status = 4; // 房主播放进度 int64 progress = 5; // 提示信息 MessageProto message = 6; } // 房间状态更新 message RoomUpdateEvent { // 房间id int64 room_id = 1; // 房间变更状态 RoomType type = 2; // 提示信息 MessageProto message = 3; } // 房间销毁通知 message RoomDestroyEvent { // 房间id int64 room_id = 1; // 提示信息 MessageProto message = 4; } // 房间触发通知 message RoomTriggerEvent { // 操作人 int64 mid = 1; // 提示信息 MessageProto message = 2; // 触发类型 TriggerType trigger = 3; } //用户信息 message UserInfoProto { // 用户id int64 mid = 1; // 用户头像url string face = 2; // 昵称 string nickname = 3; // 等级 int32 level = 4; // 签名 string sign = 5; // 大会员信息 VipProto vip = 6; // 身份认证信息 OfficialProto official = 7; // 挂件信息 PendantProto pendant = 8; // 设备buvid string buvid = 9; } //通知信息 message MessageProto { // 可带占位符匹配的消息体 ep "还没有其他小伙伴,[去邀请>]" string content = 1; // 消息体类型 // 0:json格式的文本消息 1:支持全文本可点(破冰) int32 content_type = 2; } //大会员信息 message VipProto { int32 type = 1; int32 status = 2; int64 due_date = 3; int32 vip_pay_type = 4; int32 theme_type = 5; // 大会员角标 // 0:无角标 1:粉色大会员角标 2:绿色小会员角标 int32 avatar_subscript = 6; // 昵称色值,可能为空,色值示例:#FFFB9E60 string nickname_color = 7; } //认证信息 message OfficialProto { int32 role = 1; string title = 2; string desc = 3; int32 type = 4; } //挂件信息 message PendantProto { int32 pid = 1; string name = 2; string image = 3; int64 expire = 4; string image_enhance = 5; } // 通用信息通知 message MessageEvent { // 房间id int64 room_id = 1; // 消息id int64 msg_id = 2; // 消息发送服务端时间 时间戳 单位秒 int64 ts = 3; // 信息通知发送主体id int64 oid = 4; // 信息通知发送领域 MessageDomain domain = 5; // 通知信息类型 MessageType type = 6; // 提示信息 MessageProto message = 7; // 消息发送用户信息 UserInfoProto user = 8; // 消息id str类型 string msg_id2 = 9; } // 聊天信息清除通知 message RemoveChatEvent { // 房间id int64 room_id = 1; // 撤回的聊天信息id int64 msg_id = 2; // 提示信息 MessageProto message = 3; } // "一起看"房间事件 message FreyaEventBody { // 房间id int64 room_id = 1; // 接收事件消息的白名单用户 repeated int64 white_mid = 2; // 不处理信息的黑名单用户 优先级低于白名单 当白名单有数据时 忽略黑名单 repeated int64 ignore_mid = 3; //命令类型 oneof event { // 房间人员变更事件 RoomMemberChangeEvent member_change = 4; // 播放进度同步事件 ProgressSyncEvent progress = 5; // 房间状态更新 RoomUpdateEvent room_update = 6; // 通用信息通知 MessageEvent message = 7; // 聊天信息清除通知 RemoveChatEvent remove_chat = 8; // 房间销毁通知 RoomDestroyEvent room_destroy = 9; // 房间触发通知 RoomTriggerEvent room_trigger = 10; } // 消息序列号 int64 sequence_id = 100; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/ogv/live.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.ogv; option java_multiple_files = true; // 开播事件 message LiveStartEvent {} // 直播中止事件 message LiveEndEvent {} // 在线人数事件 message LiveOnlineEvent { //在线人数 int64 online = 1; } // 变更通知 message LiveUpdateEvent { // 直播后状态 // 1:下线 2:转点播 int32 after_premiere_type = 1; // 直播开始绝对时间 单位ms int64 start_time = 2; // id string id = 3; // 服务端播放进度,未打散,负数表示距离开播时间,正数表示已开播时间,单位:毫秒 // 用户实际播放进度:progress - delay_time int64 progress = 4; } // 直播间事件 message CMDBody { //命令类型 oneof event { // 开播事件 LiveStartEvent start = 1; // 直播中止事件 LiveEndEvent emergency = 2; // 在线人数事件 LiveOnlineEvent online = 3; // 变更通知 LiveUpdateEvent update = 4; } } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/ticket/activitygame.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.ticket; option java_multiple_files = true; // enum RoomStatus { // 暂停: Pause = 0; // 播放: Play = 1; // 终止: End = 2; } // 推送选项 message RoomEvent { // RoomStatus 类型 RoomStatus room_status = 1; // string room_message = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/message/tv/proj.proto ================================================ syntax = "proto3"; package bilibili.broadcast.message.tv; option java_multiple_files = true; import "google/protobuf/empty.proto"; // service Tv { // 投屏 rpc Proj(google.protobuf.Empty) returns (stream ProjReply); // 直播状态 rpc LiveStatus(google.protobuf.Empty) returns (stream LiveStatusNotify); // 赛事比分通知 rpc Esports(google.protobuf.Empty) returns (stream EsportsNotify); // 直播插卡 rpc Publicity(google.protobuf.Empty) returns (stream PublicityNotify); // 直转点 rpc LiveSkip(google.protobuf.Empty) returns (stream LiveSkipNotify); } // 投屏 message ProjReply { // 投屏命令 // 1:起播 2:快进 3:快退 4:seek播放进度 5:暂停 6:暂停恢复 int64 cmd_type = 1; // 用户id int64 mid = 2; // 稿件id int64 aid = 3; // 视频id int64 cid = 4; // 视频类型 // 0:ugc 1:pgc 2:pugv int64 video_type = 5; // 单集id,pgc和pugv需要传 int64 ep_id = 6; // 剧集id int64 season_id = 7; // seek 的位置,cmd位seek时有值,单位秒 int64 seek_ts = 8; // 其他指令对应内容 string extra = 9; } // 直播状态 message LiveStatusNotify { // 直播状态 // 1:开播 2:关播 3:截流 4:截流恢复 int64 status = 1; // 文案 string msg = 2; // 直播房间号 int64 cid = 3; } // message EsportsNotify { // 直播房间号 int64 cid = 1; } // 直播插卡 message PublicityNotify { // 插卡id int64 publicity_id = 1; // 直播房间号 int64 room_id = 2; // 直播间状态 // 0:未开播 1:直播中 2:轮播中 int64 status = 3; } // 直转点 message LiveSkipNotify { // 直播id int64 live_id = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/v1/broadcast.proto ================================================ syntax = "proto3"; package bilibili.broadcast.v1; option java_multiple_files = true; import "bilibili/rpc/status.proto"; import "google/protobuf/any.proto"; import "google/protobuf/empty.proto"; // broadcast操作,对应每个target_path service Broadcast { // 用户鉴权 rpc Auth(AuthReq) returns (AuthResp); // 心跳保活:成功心跳为4分45秒,重试心跳为30s,三次收不到进行重连(不超过5分45) rpc Heartbeat(HeartbeatReq) returns (HeartbeatResp); // 订阅target_path rpc Subscribe(TargetPath) returns (google.protobuf.Empty); // 取消订阅target_path rpc Unsubscribe(TargetPath) returns (google.protobuf.Empty); // 消息回执 rpc MessageAck(MessageAckReq) returns (google.protobuf.Empty); } // broadcast连接隧道 service BroadcastTunnel { // 创建双向stream连接隧道 rpc CreateTunnel(stream BroadcastFrame) returns (stream BroadcastFrame); } // enum Action { UNKNOWN = 0; // UPDATE = 1; // DELETE = 2; // } // 鉴权请求,通过authorization验证绑定用户mid message AuthReq { // 冷启动id,算法uuid,重新起启会变 string guid = 1; // 连接id,算法uuid,重连会变 string conn_id = 2; // 最后收到的消息id,用于过虑重连后获取未读的消息 int64 last_msg_id = 3; } // 鉴权返回 message AuthResp { } // target_path: // "/" Service-Name "/" {method name} 参考 gRPC Request Path message BroadcastFrame { // 请求消息信息 FrameOption options = 1; // 业务target_path string target_path = 2; // 业务pb内容 google.protobuf.Any body = 3; } // message_id: // client: 本次连接唯一的消息id,可用于回执 // server: 唯一消息id,可用于上报或者回执 // sequence: // client: 客户端应该每次请求时frame seq++,会返回对应的对称req/resp // server: 服务端下行消息,只会返回默认值:0 message FrameOption { // 消息id int64 message_id = 1; // frame序号 int64 sequence = 2; // 是否进行消息回执(发出MessageAckReq) // downstream 上只有服务端设置为true,客户端响应 // upstream 上只有客户端设置为true,服务端响应 // 响应帧禁止设置is_ack,协议上禁止循环 // 通常只有业务帧才可能设置is_ack, 因为协议栈(例如心跳、鉴权)另有响应约定 bool is_ack = 3; // 业务状态码 bilibili.rpc.Status status = 4; // 业务ack来源, 仅downstream时候由服务端填写. string ack_origin = 5; // int64 timestamp = 6; } // 心跳请求 message HeartbeatReq{ } // 心跳返回 message HeartbeatResp{ } // 消息回执 message MessageAckReq { // 消息id int64 ack_id = 1; // ack来源,由业务指定用于埋点跟踪 string ack_origin = 2; // 消息对应的target_path,方便业务区分和监控统计 string target_path = 3; } // target_path message TargetPath { // 需要订阅的target_paths repeated string target_paths = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/v1/laser.proto ================================================ syntax = "proto3"; package bilibili.broadcast.v1; option java_multiple_files = true; import "google/protobuf/empty.proto"; // Laser service Laser { // 监听上报事件 rpc WatchLogUploadEvent(google.protobuf.Empty) returns (stream LaserLogUploadResp); } // 服务端下发日志上报事件 message LaserLogUploadResp { // 任务id int64 taskid = 1; // 下发时间 string date = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/v1/mod.proto ================================================ syntax = "proto3"; package bilibili.broadcast.v1; option java_multiple_files = true; import "google/protobuf/empty.proto"; // ModManager service ModManager { // rpc WatchResource(google.protobuf.Empty) returns (stream ModResourceResp); } // message ModResourceResp { // int32 atcion = 1; // string app_key = 2; // string pool_name = 3; // string module_name = 4; // int64 module_version = 5; // int64 list_version = 6; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/v1/push.proto ================================================ syntax = "proto3"; package bilibili.broadcast.v1; option java_multiple_files = true; import "google/protobuf/empty.proto"; // Push service Push { rpc WatchMessage(google.protobuf.Empty) returns (stream PushMessageResp); } // enum LinkType { LINK_TYPE_UNKNOWN = 0; // 未知 LINK_TYPE_BANGUMI = 1; // 番剧 LINK_TYPE_VIDEO = 2; // 视频 LINK_TYPE_LIVE = 3; // 直播 } // message PageBlackList { // string id = 1; } // message PageView { // string id = 1; } // message PushMessageResp { // 业务类型 enum Biz { // 未知 BIZ_UNKNOWN = 0; // 视频 BIZ_VIDEO = 1; // 直播 BIZ_LIVE = 2; // 活动 BIZ_ACTIVITY = 3; } // 消息类型 enum Type { // 未知 TYPE_UNKNOWN = 0; // 默认 TYPE_DEFAULT = 1; // 热门 TYPE_HOT = 2; // 实时 TYPE_REALTIME = 3; // 推荐 TYPE_RECOMMEND = 4; } // 展示未知 enum Position { // 未知 POS_UNKNOWN = 0; // 顶部 POS_TOP = 1; } // Deprecated: 推送任务id,使用string int64 old_taskid = 1; // 业务 // 1:是视频 2:是直播 3:是活动 Biz biz = 2; // 类型 // 1:是默认 2:是热门 3:是实时 4:是推荐 Type type = 3; // 主标题 string title = 4; // 副标题 string summary = 5; // 图片地址 string img = 6; // 跳转地址 string link = 7; // 展示位置,1是顶部 Position position = 8; // 展示时长(单位:秒),默认3秒 int32 duration = 9; // 失效时间 int64 expire = 10; // 推送任务id string taskid = 11; // 应用内推送黑名单 // UGC: ugc-video-detail // PGC: pgc-video-detail // 一起看: pgc-video-detail-theater // 直播: live-room-detail // Story: ugc-video-detail-vertical // 播单黑名单 playlist-video-detail repeated PageBlackList page_blackList = 12; // 预留pvid repeated PageView page_view = 13; // 跳转资源 TargetResource target_resource = 14; // int32 image_frame = 15; // int32 image_marker = 16; // int32 image_position = 17; // int64 job = 18; } // message TargetResource { //直播: roomid //UGC: avid //PGC: seasonid //Story: avid //举个例子 //Type: LINK_TYPE_BANGUMI (番剧) //Resource: {"seasonid":"123"} // //Type: LINK_TYPE_VIDEO (视频) //Resource: {"avid":"123"} // //Type: LINK_TYPE_LIVE (直播) //Resource: {"roomid":"123"} // LinkType Type = 1; // map Resource = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/v1/room.proto ================================================ syntax = "proto3"; package bilibili.broadcast.v1; option java_multiple_files = true; import "bilibili/rpc/status.proto"; import "google/protobuf/any.proto"; // service BroadcastRoom { // rpc Enter(stream RoomReq) returns (stream RoomResp); } // message RoomErrorEvent { // bilibili.rpc.Status status = 1; } // message RoomJoinEvent { } // message RoomLeaveEvent { } // message RoomMessageEvent { // string target_path = 1; // google.protobuf.Any body = 2; } // message RoomOnlineEvent { // int32 online = 1; // int32 all_online = 2; } // message RoomReq { // {type}://{room_id} string id = 1; oneof event { // RoomJoinEvent join = 2; // RoomLeaveEvent leave = 3; // RoomOnlineEvent online = 4; // RoomMessageEvent msg = 5; } } // message RoomResp { // {type}://{room_id} string id = 1; oneof event { // RoomJoinEvent join = 2; // RoomLeaveEvent leave = 3; // RoomOnlineEvent online = 4; // RoomMessageEvent msg = 5; // RoomErrorEvent err = 6; } } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/v1/test.proto ================================================ syntax = "proto3"; package bilibili.broadcast.v1; option java_multiple_files = true; import "google/protobuf/any.proto"; import "google/protobuf/empty.proto"; // 服务端下发的测试专用消息,客户端debug/release包都会通过弹窗响应该消息 // 后端平台 必须 限制该消息只能针对单个用户发送 // Test service Test { // 监听上报事件 rpc WatchTestEvent(google.protobuf.Empty) returns (stream TestResp); } // service Test2 { // rpc Test(AddParams) returns (google.protobuf.Empty); } // message AddParams { // int32 a = 1; // int32 b = 2; } // message AddResult { // int32 r = 1; } message TestResp { // 任务id int64 taskid = 1; // 时间戳 int64 timestamp = 2; // 消息 string message = 3; // 扩展 google.protobuf.Any extra = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/broadcast/v2/laser.proto ================================================ syntax = "proto3"; package bilibili.broadcast.v2; option java_multiple_files = true; import "google/protobuf/empty.proto"; // Laser service Laser { // 监听Laser事件 rpc WatchEvent(google.protobuf.Empty) returns (stream LaserEventResp); } // 服务端下发Laser事件 message LaserEventResp { // 任务id int64 taskid = 1; // 指令名 string action = 2; // 指令参数json字符串 string params = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/cheese/gateway/player/v1/playurl.proto ================================================ syntax = "proto3"; package bilibili.cheese.gateway.player.v1; option java_multiple_files = true; import "bilibili/app/playurl/v1/playurl.proto"; // 课程视频url service PlayURL { // 播放页信息 rpc PlayView (PlayViewReq) returns (PlayViewReply); // 投屏地址 rpc Project (ProjectReq) returns (ProjectReply); } // 播放页信息-请求 message PlayViewReq { // 课程epid(与番剧不互通) int64 ep_id = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string from_spmid = 10; // 青少年模式 int32 teenagers_mode = 11; // 视频编码 bilibili.app.playurl.v1.CodeType prefer_codec_type = 12; // 是否强制请求预览视频 bool is_preview = 13; } // 投屏地址-请求 message ProjectReq { // 课程epid(与番剧不互通) int64 ep_id = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string from_spmid = 10; // 投屏协议 // 0:默认乐播 1:自建协议 2:云投屏 int32 protocol = 11; // 投屏设备 // 0:默认其他 1:OTT设备 int32 device_type = 12; // 是否flv格式 bool flv_proj = 13; } // 播放页信息-响应 message PlayViewReply { // 视频url信息 bilibili.app.playurl.v1.VideoInfo video_info = 1; // 禁用功能配置 PlayAbilityConf play_conf = 2; } // 禁用功能配置 message PlayAbilityConf { bool background_play_disable = 1; // 后台播放 bool flip_disable = 2; // 镜像反转 bool cast_disable = 3; // 支持投屏 bool feedback_disable = 4; // 反馈 bool subtitle_disable = 5; // 字幕 bool playback_rate_disable = 6; // 播放速度 bool time_up_disable = 7; // 定时停止播放 bool playback_mode_disable = 8; // 播放方式 bool scale_mode_disable = 9; // 画面尺寸 bool like_disable = 10; // 顶 bool dislike_disable = 11; // 踩 bool coin_disable = 12; // 投币 bool elec_disable = 13; // 充电 bool share_disable = 14; // 分享 bool screen_shot_disable = 15; // 截图 bool lock_screen_disable = 16; // 锁屏 bool recommend_disable = 17; // 相关推荐 bool playback_speed_disable = 18; // 倍速 bool definition_disable = 19; // 清晰度 bool selections_disable = 20; // 选集 bool next_disable = 21; // 下一集 bool edit_dm_disable = 22; // 编辑弹幕 bool outer_dm_disable = 25; // 外层面板弹幕设置 bool inner_dm_disable = 26; // 三点内弹幕设置 bool small_window_disable = 27; // 画中画 } // 投屏地址-响应 message ProjectReply { bilibili.app.playurl.v1.PlayURLReply project = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/community/service/dm/v1/dm.proto ================================================ syntax = "proto3"; package bilibili.community.service.dm.v1; option java_multiple_files = true; //弹幕 service DM { // 获取分段弹幕 rpc DmSegMobile (DmSegMobileReq) returns (DmSegMobileReply); // 客户端弹幕元数据 字幕、分段、防挡蒙版等 rpc DmView(DmViewReq) returns (DmViewReply); // 修改弹幕配置 rpc DmPlayerConfig (DmPlayerConfigReq) returns (Response); // ott弹幕列表 rpc DmSegOtt(DmSegOttReq) returns(DmSegOttReply); // SDK弹幕列表 rpc DmSegSDK(DmSegSDKReq) returns(DmSegSDKReply); // rpc DmExpoReport(DmExpoReportReq) returns (DmExpoReportRes); } // message Avatar { // string id = 1; // string url = 2; // AvatarType avatar_type = 3; } // enum AvatarType { AvatarTypeNone = 0; // AvatarTypeNFT = 1; // } // message Bubble { // string text = 1; // string url = 2; } // enum BubbleType { BubbleTypeNone = 0; // BubbleTypeClickButton = 1; // BubbleTypeDmSettingPanel = 2; // } // message BubbleV2 { // string text = 1; // string url = 2; // BubbleType bubble_type = 3; // bool exposure_once = 4; // ExposureType exposure_type = 5; } // message Button { // string text = 1; // int32 action = 2; } // message BuzzwordConfig { // repeated BuzzwordShowConfig keywords = 1; } // message BuzzwordShowConfig { // string name = 1; // string schema = 2; // int32 source = 3; // int64 id = 4; // int64 buzzword_id = 5; // int32 schema_type = 6; } // message CheckBox { // string text = 1; // CheckboxType type = 2; // bool default_value = 3; // bool show = 4; } // enum CheckboxType { CheckboxTypeNone = 0; // CheckboxTypeEncourage = 1; // CheckboxTypeColorDM = 2; // } // message CheckBoxV2 { // string text = 1; // int32 type = 2; // bool default_value = 3; } // message ClickButton { // repeated string portrait_text = 1; // repeated string landscape_text = 2; // repeated string portrait_text_focus = 3; // repeated string landscape_text_focus = 4; // RenderType render_type = 5; // bool show = 6; // Bubble bubble = 7; } // message ClickButtonV2 { // repeated string portrait_text = 1; // repeated string landscape_text = 2; // repeated string portrait_text_focus = 3; // repeated string landscape_text_focus = 4; // int32 render_type = 5; // bool text_input_post = 6; // bool exposure_once = 7; // int32 exposure_type = 8; } // 互动弹幕条目信息 message CommandDm { // 弹幕id int64 id = 1; // 对象视频cid int64 oid = 2; // 发送者mid string mid = 3; // 互动弹幕指令 string command = 4; // 互动弹幕正文 string content = 5; // 出现时间 int32 progress = 6; // 创建时间 string ctime = 7; // 发布时间 string mtime = 8; // 扩展json数据 string extra = 9; // 弹幕id str类型 string idStr = 10; } // 弹幕ai云屏蔽列表 message DanmakuAIFlag { // 弹幕ai云屏蔽条目 repeated DanmakuFlag dm_flags = 1; } // 弹幕条目 message DanmakuElem { // 弹幕dmid int64 id = 1; // 弹幕出现位置(单位ms) int32 progress = 2; // 弹幕类型 1 2 3:普通弹幕 4:底部弹幕 5:顶部弹幕 6:逆向弹幕 7:高级弹幕 8:代码弹幕 9:BAS弹幕(pool必须为2) int32 mode = 3; // 弹幕字号 int32 fontsize = 4; // 弹幕颜色 uint32 color = 5; // 发送者mid hash string midHash = 6; // 弹幕正文 string content = 7; // 发送时间 int64 ctime = 8; // 权重 用于屏蔽等级 区间:[1,10] int32 weight = 9; // 动作 string action = 10; // 弹幕池 0:普通池 1:字幕池 2:特殊池(代码/BAS弹幕) int32 pool = 11; // 弹幕dmid str string idStr = 12; // 弹幕属性位(bin求AND) // bit0:保护 bit1:直播 bit2:高赞 int32 attr = 13; // string animation = 22; // 大会员专属颜色 DmColorfulType colorful = 24; } // 弹幕ai云屏蔽条目 message DanmakuFlag { // 弹幕dmid int64 dmid = 1; // 评分 uint32 flag = 2; } // 云屏蔽配置信息 message DanmakuFlagConfig { // 云屏蔽等级 int32 rec_flag = 1; // 云屏蔽文案 string rec_text = 2; // 云屏蔽开关 int32 rec_switch = 3; } // 弹幕默认配置 message DanmuDefaultPlayerConfig { bool player_danmaku_use_default_config = 1; // 是否使用推荐弹幕设置 bool player_danmaku_ai_recommended_switch = 4; // 是否开启智能云屏蔽 int32 player_danmaku_ai_recommended_level = 5; // 智能云屏蔽等级 bool player_danmaku_blocktop = 6; // 是否屏蔽顶端弹幕 bool player_danmaku_blockscroll = 7; // 是否屏蔽滚动弹幕 bool player_danmaku_blockbottom = 8; // 是否屏蔽底端弹幕 bool player_danmaku_blockcolorful = 9; // 是否屏蔽彩色弹幕 bool player_danmaku_blockrepeat = 10; // 是否屏蔽重复弹幕 bool player_danmaku_blockspecial = 11; // 是否屏蔽高级弹幕 float player_danmaku_opacity = 12; // 弹幕不透明度 float player_danmaku_scalingfactor = 13; // 弹幕缩放比例 float player_danmaku_domain = 14; // 弹幕显示区域 int32 player_danmaku_speed = 15; // 弹幕速度 bool inline_player_danmaku_switch = 16; // 是否开启弹幕 int32 player_danmaku_senior_mode_switch = 17; // int32 player_danmaku_ai_recommended_level_v2 = 18; // map player_danmaku_ai_recommended_level_v2_map = 19; // } // 弹幕配置 message DanmuPlayerConfig { bool player_danmaku_switch = 1; // 是否开启弹幕 bool player_danmaku_switch_save = 2; // 是否记录弹幕开关设置 bool player_danmaku_use_default_config = 3; // 是否使用推荐弹幕设置 bool player_danmaku_ai_recommended_switch = 4; // 是否开启智能云屏蔽 int32 player_danmaku_ai_recommended_level = 5; // 智能云屏蔽等级 bool player_danmaku_blocktop = 6; // 是否屏蔽顶端弹幕 bool player_danmaku_blockscroll = 7; // 是否屏蔽滚动弹幕 bool player_danmaku_blockbottom = 8; // 是否屏蔽底端弹幕 bool player_danmaku_blockcolorful = 9; // 是否屏蔽彩色弹幕 bool player_danmaku_blockrepeat = 10; // 是否屏蔽重复弹幕 bool player_danmaku_blockspecial = 11; // 是否屏蔽高级弹幕 float player_danmaku_opacity = 12; // 弹幕不透明度 float player_danmaku_scalingfactor = 13; // 弹幕缩放比例 float player_danmaku_domain = 14; // 弹幕显示区域 int32 player_danmaku_speed = 15; // 弹幕速度 bool player_danmaku_enableblocklist = 16; // 是否开启屏蔽列表 bool inline_player_danmaku_switch = 17; // 是否开启弹幕 int32 inline_player_danmaku_config = 18; // int32 player_danmaku_ios_switch_save = 19; // int32 player_danmaku_senior_mode_switch = 20; // int32 player_danmaku_ai_recommended_level_v2 = 21; // map player_danmaku_ai_recommended_level_v2_map = 22; // } // message DanmuPlayerConfigPanel { // string selection_text = 1; } // 弹幕显示区域自动配置 message DanmuPlayerDynamicConfig { // 时间 int32 progress = 1; // 弹幕显示区域 float player_danmaku_domain = 14; } // 弹幕配置信息 message DanmuPlayerViewConfig { // 弹幕默认配置 DanmuDefaultPlayerConfig danmuku_default_player_config = 1; // 弹幕用户配置 DanmuPlayerConfig danmuku_player_config = 2; // 弹幕显示区域自动配置列表 repeated DanmuPlayerDynamicConfig danmuku_player_dynamic_config = 3; // DanmuPlayerConfigPanel danmuku_player_config_panel = 4; } // web端用户弹幕配置 message DanmuWebPlayerConfig { bool dm_switch = 1; // 是否开启弹幕 bool ai_switch = 2; // 是否开启智能云屏蔽 int32 ai_level = 3; // 智能云屏蔽等级 bool blocktop = 4; // 是否屏蔽顶端弹幕 bool blockscroll = 5; // 是否屏蔽滚动弹幕 bool blockbottom = 6; // 是否屏蔽底端弹幕 bool blockcolor = 7; // 是否屏蔽彩色弹幕 bool blockspecial = 8; // 是否屏蔽重复弹幕 bool preventshade = 9; // bool dmask = 10; // float opacity = 11; // int32 dmarea = 12; // float speedplus = 13; // float fontsize = 14; // 弹幕字号 bool screensync = 15; // bool speedsync = 16; // string fontfamily = 17; // bool bold = 18; // 是否使用加粗 int32 fontborder = 19; // string draw_type = 20; // 弹幕渲染类型 int32 senior_mode_switch = 21; // int32 ai_level_v2 = 22; // map ai_level_v2_map = 23; // } // 弹幕属性位值 enum DMAttrBit { DMAttrBitProtect = 0; // 保护弹幕 DMAttrBitFromLive = 1; // 直播弹幕 DMAttrHighLike = 2; // 高赞弹幕 } message DmColorful { DmColorfulType type = 1; // 颜色类型 string src = 2; // } enum DmColorfulType { NoneType = 0; // 无 VipGradualColor = 60001; // 渐变色 } // message DmExpoReportReq { // string session_id = 1; // int64 oid = 2; // string spmid = 4; } // message DmExpoReportRes {} // 修改弹幕配置-请求 message DmPlayerConfigReq { int64 ts = 1; // PlayerDanmakuSwitch switch = 2; // 是否开启弹幕 PlayerDanmakuSwitchSave switch_save = 3; // 是否记录弹幕开关设置 PlayerDanmakuUseDefaultConfig use_default_config = 4; // 是否使用推荐弹幕设置 PlayerDanmakuAiRecommendedSwitch ai_recommended_switch = 5; // 是否开启智能云屏蔽 PlayerDanmakuAiRecommendedLevel ai_recommended_level = 6; // 智能云屏蔽等级 PlayerDanmakuBlocktop blocktop = 7; // 是否屏蔽顶端弹幕 PlayerDanmakuBlockscroll blockscroll = 8; // 是否屏蔽滚动弹幕 PlayerDanmakuBlockbottom blockbottom = 9; // 是否屏蔽底端弹幕 PlayerDanmakuBlockcolorful blockcolorful = 10; // 是否屏蔽彩色弹幕 PlayerDanmakuBlockrepeat blockrepeat = 11; // 是否屏蔽重复弹幕 PlayerDanmakuBlockspecial blockspecial = 12; // 是否屏蔽高级弹幕 PlayerDanmakuOpacity opacity = 13; // 弹幕不透明度 PlayerDanmakuScalingfactor scalingfactor = 14; // 弹幕缩放比例 PlayerDanmakuDomain domain = 15; // 弹幕显示区域 PlayerDanmakuSpeed speed = 16; // 弹幕速度 PlayerDanmakuEnableblocklist enableblocklist = 17; // 是否开启屏蔽列表 InlinePlayerDanmakuSwitch inlinePlayerDanmakuSwitch = 18; // 是否开启弹幕 PlayerDanmakuSeniorModeSwitch senior_mode_switch = 19; // PlayerDanmakuAiRecommendedLevelV2 ai_recommended_level_v2 = 20; // } // message DmSegConfig { // int64 page_size = 1; // int64 total = 2; } // 获取弹幕-响应 message DmSegMobileReply { // 弹幕列表 repeated DanmakuElem elems = 1; // 是否已关闭弹幕 // 0:未关闭 1:已关闭 int32 state = 2; // 弹幕云屏蔽ai评分值 DanmakuAIFlag ai_flag = 3; repeated DmColorful colorfulSrc = 5; } // 获取弹幕-请求 message DmSegMobileReq { // 稿件avid/漫画epid int64 pid = 1; // 视频cid/漫画cid int64 oid = 2; // 弹幕类型 // 1:视频 2:漫画 int32 type = 3; // 分段(6min) int64 segment_index = 4; // 是否青少年模式 int32 teenagers_mode = 5; // int64 ps = 6; // int64 pe = 7; // int32 pull_mode = 8; // int32 from_scene = 9; } // ott弹幕列表-响应 message DmSegOttReply { // 是否已关闭弹幕 // 0:未关闭 1:已关闭 bool closed = 1; // 弹幕列表 repeated DanmakuElem elems = 2; } // ott弹幕列表-请求 message DmSegOttReq { // 稿件avid/漫画epid int64 pid = 1; // 视频cid/漫画cid int64 oid = 2; // 弹幕类型 // 1:视频 2:漫画 int32 type = 3; // 分段(6min) int64 segment_index = 4; } // 弹幕SDK-响应 message DmSegSDKReply { // 是否已关闭弹幕 // 0:未关闭 1:已关闭 bool closed = 1; // 弹幕列表 repeated DanmakuElem elems = 2; } // 弹幕SDK-请求 message DmSegSDKReq { // 稿件avid/漫画epid int64 pid = 1; // 视频cid/漫画cid int64 oid = 2; // 弹幕类型 // 1:视频 2:漫画 int32 type = 3; // 分段(6min) int64 segment_index = 4; } // 客户端弹幕元数据-响应 message DmViewReply { // 是否已关闭弹幕 // 0:未关闭 1:已关闭 bool closed = 1; // 智能防挡弹幕蒙版信息 VideoMask mask = 2; // 视频字幕 VideoSubtitle subtitle = 3; // 高级弹幕专包url(bfs) repeated string special_dms = 4; // 云屏蔽配置信息 DanmakuFlagConfig ai_flag = 5; // 弹幕配置信息 DanmuPlayerViewConfig player_config = 6; // 弹幕发送框样式 int32 send_box_style = 7; // 是否允许 bool allow = 8; // check box 是否展示 string check_box = 9; // check box 展示文本 string check_box_show_msg = 10; // 展示文案 string text_placeholder = 11; // 弹幕输入框文案 string input_placeholder = 12; // 用户举报弹幕 cid维度屏蔽的正则规则 repeated string report_filter_content = 13; // ExpoReport expo_report = 14; // BuzzwordConfig buzzword_config = 15; // repeated Expressions expressions = 16; // repeated PostPanel post_panel = 17; // repeated string activity_meta = 18; // repeated PostPanelV2 post_panel2 = 19; } // 客户端弹幕元数据-请求 message DmViewReq { // 稿件avid/漫画epid int64 pid = 1; // 视频cid/漫画cid int64 oid = 2; // 弹幕类型 // 1:视频 2:漫画 int32 type = 3; // 页面spm string spmid = 4; // 是否冷启 int32 is_hard_boot = 5; } // web端弹幕元数据-响应 // https://api.bilibili.com/x/v2/dm/web/view message DmWebViewReply { // 是否已关闭弹幕 // 0:未关闭 1:已关闭 int32 state = 1; // string text = 2; // string text_side = 3; // 分段弹幕配置 DmSegConfig dm_sge = 4; // 云屏蔽配置信息 DanmakuFlagConfig flag = 5; // 高级弹幕专包url(bfs) repeated string special_dms = 6; // check box 是否展示 bool check_box = 7; // 弹幕数 int64 count = 8; // 互动弹幕 repeated CommandDm commandDms = 9; // 用户弹幕配置 DanmuWebPlayerConfig player_config = 10; // 用户举报弹幕 cid维度屏蔽 repeated string report_filter_content = 11; // repeated Expressions expressions = 12; // repeated PostPanel post_panel = 13; // repeated string activity_meta = 14; } // message ExpoReport { // bool should_report_at_end = 1; } // enum ExposureType { ExposureTypeNone = 0; // ExposureTypeDMSend = 1; // } // message Expression { // repeated string keyword = 1; // string url = 2; // repeated Period period = 3; } // message Expressions { // repeated Expression data = 1; } // 是否开启弹幕 message InlinePlayerDanmakuSwitch { // bool value = 1; } // message Label { // string title = 1; // repeated string content = 2; } // message LabelV2 { // string title = 1; // repeated string content = 2; // bool exposure_once = 3; // int32 exposure_type = 4; } // message Period { // int64 start = 1; // int64 end = 2; } message PlayerDanmakuAiRecommendedLevel {bool value = 1;} // 智能云屏蔽等级 message PlayerDanmakuAiRecommendedLevelV2 {int32 value = 1;} // message PlayerDanmakuAiRecommendedSwitch {bool value = 1;} // 是否开启智能云屏蔽 message PlayerDanmakuBlockbottom {bool value = 1;} // 是否屏蔽底端弹幕 message PlayerDanmakuBlockcolorful {bool value = 1;} // 是否屏蔽彩色弹幕 message PlayerDanmakuBlockrepeat {bool value = 1;} // 是否屏蔽重复弹幕 message PlayerDanmakuBlockscroll {bool value = 1;} // 是否屏蔽滚动弹幕 message PlayerDanmakuBlockspecial {bool value = 1;} // 是否屏蔽高级弹幕 message PlayerDanmakuBlocktop {bool value = 1;} // 是否屏蔽顶端弹幕 message PlayerDanmakuDomain {float value = 1;} // 弹幕显示区域 message PlayerDanmakuEnableblocklist {bool value = 1;} // 是否开启屏蔽列表 message PlayerDanmakuOpacity {float value = 1;} // 弹幕不透明度 message PlayerDanmakuScalingfactor {float value = 1;} // 弹幕缩放比例 message PlayerDanmakuSeniorModeSwitch {int32 value = 1;} // message PlayerDanmakuSpeed {int32 value = 1;} // 弹幕速度 message PlayerDanmakuSwitch {bool value = 1; bool can_ignore = 2;} // 是否开启弹幕 message PlayerDanmakuSwitchSave {bool value = 1;} // 是否记录弹幕开关设置 message PlayerDanmakuUseDefaultConfig {bool value = 1;} // 是否使用推荐弹幕设置 // message PostPanel { // int64 start = 1; // int64 end = 2; // int64 priority = 3; // int64 biz_id = 4; // PostPanelBizType biz_type = 5; // ClickButton click_button = 6; // TextInput text_input = 7; // CheckBox check_box = 8; // Toast toast = 9; } // enum PostPanelBizType { PostPanelBizTypeNone = 0; // PostPanelBizTypeEncourage = 1; // PostPanelBizTypeColorDM = 2; // PostPanelBizTypeNFTDM = 3; // PostPanelBizTypeFragClose = 4; // PostPanelBizTypeRecommend = 5; // } // message PostPanelV2 { // int64 start = 1; // int64 end = 2; // int32 biz_type = 3; // ClickButtonV2 click_button = 4; // TextInputV2 text_input = 5; // CheckBoxV2 check_box = 6; // ToastV2 toast = 7; // BubbleV2 bubble = 8; // LabelV2 label = 9; // int32 post_status = 10; } // enum PostStatus { PostStatusNormal = 0; // PostStatusClosed = 1; // } // enum RenderType { RenderTypeNone = 0; // RenderTypeSingle = 1; // RenderTypeRotation = 2; // } // 修改弹幕配置-响应 message Response { // int32 code = 1; // string message = 2; } // enum SubtitleAiStatus { None = 0; // Exposure = 1; // Assist = 2; // } // enum SubtitleAiType { Normal = 0; // Translate = 1; // } // 单个字幕信息 message SubtitleItem { // 字幕id int64 id = 1; // 字幕id str string id_str = 2; // 字幕语言代码 string lan = 3; // 字幕语言 string lan_doc = 4; // 字幕文件url string subtitle_url = 5; // 字幕作者信息 UserInfo author = 6; // 字幕类型 SubtitleType type = 7; // string lan_doc_brief = 8; // SubtitleAiType ai_type = 9; // SubtitleAiStatus ai_status = 10; } enum SubtitleType { CC = 0; // CC字幕 AI = 1; // AI生成字幕 } // message TextInput { // repeated string portrait_placeholder = 1; // repeated string landscape_placeholder = 2; // RenderType render_type = 3; // bool placeholder_post = 4; // bool show = 5; // repeated Avatar avatar = 6; // PostStatus post_status = 7; // Label label = 8; } // message TextInputV2 { // repeated string portrait_placeholder = 1; // repeated string landscape_placeholder = 2; // RenderType render_type = 3; // bool placeholder_post = 4; // repeated Avatar avatar = 5; // int32 text_input_limit = 6; } // message Toast { // string text = 1; // int32 duration = 2; // bool show = 3; // Button button = 4; } // message ToastButtonV2 { // string text = 1; // int32 action = 2; } // enum ToastFunctionType { ToastFunctionTypeNone = 0; // ToastFunctionTypePostPanel = 1; // } // message ToastV2 { // string text = 1; // int32 duration = 2; // ToastButtonV2 toast_button_v2 = 3; } // 字幕作者信息 message UserInfo { // 用户mid int64 mid = 1; // 用户昵称 string name = 2; // 用户性别 string sex = 3; // 用户头像url string face = 4; // 用户签名 string sign = 5; // 用户等级 int32 rank = 6; } // 智能防挡弹幕蒙版信息 message VideoMask { // 视频cid int64 cid = 1; // 平台 // 0:web端 1:客户端 int32 plat = 2; // 帧率 int32 fps = 3; // 间隔时间 int64 time = 4; // 蒙版url string mask_url = 5; } // 视频字幕信息 message VideoSubtitle { // 视频原语言代码 string lan = 1; // 视频原语言 string lanDoc = 2; // 视频字幕列表 repeated SubtitleItem subtitles = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/community/service/govern/v1/govern.proto ================================================ syntax = "proto3"; package bilibili.community.service.govern.v1; option java_multiple_files = true; import "google/protobuf/empty.proto"; // service Qoe { // rpc QoeReport (QoeReportReq) returns (google.protobuf.Empty); } // message QoeReportReq { // int64 id = 1; // int64 scene = 2; // int32 type = 3; // bool cancel = 4; // string business_type = 5; // int64 oid = 6; // QoeScoreResult score_result = 7; // string business_data = 8; } // message QoeScoreResult { // float score = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/dagw/component/avatar/common/common.proto ================================================ syntax = "proto3"; package bilibili.dagw.component.avatar.common; option java_multiple_files = true; // message BasicRenderSpec { // double opacity = 1; } // message ColorConfig { // bool is_dark_mode_aware = 1; // ColorSpec day = 2; // ColorSpec night = 3; } // message ColorSpec { // string argb = 1; } // message LayerGeneralSpec { // PositionSpec pos_spec = 1; // SizeSpec size_spec = 2; // BasicRenderSpec render_spec = 3; } // message MaskProperty { // LayerGeneralSpec general_spec = 1; // ResourceSource mask_src = 2; } // message NativeDrawRes { // int32 draw_type = 1; // int32 fill_mode = 2; // ColorConfig color_config = 3; // double edge_weight = 4; } // message PositionSpec { // int32 coordinate_pos = 1; // double axis_x = 2; // double axis_y = 3; } // message RemoteRes { // string url = 1; // string bfs_style = 2; } // message ResourceSource { // enum LocalRes { LOCAL_RES_INVALID = 0; LOCAL_RES_ICON_VIP = 1; LOCAL_RES_ICON_SMALL_VIP = 2; LOCAL_RES_ICON_PERSONAL_VERIFY = 3; LOCAL_RES_ICON_ENTERPRISE_VERIFY = 4; LOCAL_RES_ICON_NFT_MAINLAND = 5; LOCAL_RES_DEFAULT_AVATAR = 6; } // int32 src_type = 1; // int32 placeholder = 2; // oneof res { // RemoteRes remote = 3; // LocalRes local = 4; // NativeDrawRes draw = 5; } } // message SizeSpec { // double width = 1; // double height = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/dagw/component/avatar/v1/avatar.proto ================================================ syntax = "proto3"; package bilibili.dagw.component.avatar.v1; option java_multiple_files = true; import "bilibili/dagw/component/avatar/common/common.proto"; import "bilibili/dagw/component/avatar/v1/plugin.proto"; // message AvatarItem { // bilibili.dagw.component.avatar.common.SizeSpec container_size = 1; // repeated LayerGroup layers = 2; // LayerGroup fallback_layers = 3; // int64 mid = 4; } // message BasicLayerResource { // int32 res_type = 1; // oneof payload { // ResImage res_image = 2; // ResAnimation res_animation = 3; /// ResNativeDraw res_native_draw = 4; }; } // message GeneralConfig { // map web_css_style = 1; } // message Layer { // string layer_id = 1; // bool visible = 2; // bilibili.dagw.component.avatar.common.LayerGeneralSpec general_spec = 3; // LayerConfig layer_config = 4; // BasicLayerResource resource = 5; } // message LayerConfig { // map tags = 1; // bool is_critical = 2; // bool allow_over_paint = 3; // bilibili.dagw.component.avatar.common.MaskProperty layer_mask = 4; } // message LayerGroup { // string group_id = 1; // repeated Layer layers = 2; // bilibili.dagw.component.avatar.common.MaskProperty group_mask = 3; // bool is_critical_group = 4; } // message LayerTagConfig { // int32 config_type = 1; // oneof config { // GeneralConfig general_config = 2; // bilibili.dagw.component.avatar.v1.plugin.GyroConfig gyro_config = 3; // bilibili.dagw.component.avatar.v1.plugin.CommentDoubleClickConfig comment_doubleClick_config = 4; // bilibili.dagw.component.avatar.v1.plugin.LiveAnimeConfig live_anime_config = 5; }; } // message ResAnimation { // bilibili.dagw.component.avatar.common.ResourceSource webp_src = 1; } // message ResImage { // bilibili.dagw.component.avatar.common.ResourceSource image_src = 1; } // message ResNativeDraw { // bilibili.dagw.component.avatar.common.ResourceSource draw_src = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/dagw/component/avatar/v1/plugin.proto ================================================ syntax = "proto3"; package bilibili.dagw.component.avatar.v1.plugin; option java_multiple_files = true; import "bilibili/dagw/component/avatar/common/common.proto"; // message CommentDoubleClickConfig { // Interaction interaction = 1; // double animation_scale = 2; } // message GyroConfig { // NFTImageV2 gyroscope = 1; } // message GyroscopeContentV2 { // string file_url = 1; // float scale = 2; // repeated PhysicalOrientationV2 physical_orientation = 3; } // message GyroscopeEntityV2 { // string display_type = 1; // repeated GyroscopeContentV2 contents = 2; } // message Interaction { // string nft_id = 1; // bool enabled = 2; // string itype = 3; // string metadata_url = 4; } // message LiveAnimeConfig { // bool is_live = 1; } // message LiveAnimeItem { // bilibili.dagw.component.avatar.common.ColorConfig color = 1; // double start_ratio = 2; // double end_ratio = 3; // double start_stroke = 4; // double start_opacity = 5; // int64 phase = 6; } // message NFTImageV2 { // repeated GyroscopeEntityV2 gyroscope = 1; } // message PhysicalOrientationAnimation { // string type = 1; // string bezier = 3; } // message PhysicalOrientationV2 { // string type = 1; // repeated PhysicalOrientationAnimation animations = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/dynamic/common/dynamic.proto ================================================ syntax = "proto3"; package bilibili.dynamic; option java_multiple_files = true; import "bilibili/app/dynamic/v2/dynamic.proto"; // at分组信息 message AtGroup { // 分组类型 AtGroupType group_type = 1; // 分组名称 string group_name = 2; // items repeated AtItem items = 3; } // at分组类型 enum AtGroupType { AT_GROUP_TYPE_DEFAULT = 0; // 默认 AT_GROUP_TYPE_RECENT = 1; // 最近联系 AT_GROUP_TYPE_FOLLOW = 2; // 我的关注(互相关注 > 单向关注) AT_GROUP_TYPE_FANS = 3; // 我的粉丝 AT_GROUP_TYPE_OTHERS = 4; // 其他 } // at返回单条信息 message AtItem { // mid int64 uid = 1; // 昵称 string name = 2; // 头像 string face = 3; // 粉丝数 int32 fans = 4; // 认证信息 int32 official_verify_type = 5; } // at列表-请求 message AtListReq { // mid int64 uid = 1; } // at列表-响应 message AtListRsp { // 分组信息 repeated AtGroup groups = 1; } // at搜索-请求 message AtSearchReq { // mid int64 uid = 1; // 关键字 string keyword = 2; } // enum AttachCardType { ATTACH_CARD_NONE = 0; // 无 ATTACH_CARD_GOODS = 1; // 商品卡 ATTACH_CARD_VOTE = 2; // 投票卡 ATTACH_CARD_UGC = 3; // ugc视频卡 ATTACH_CARD_ACTIVITY = 4; // 帮推 ATTACH_CARD_OFFICIAL_ACTIVITY = 5; // 官方活动 ATTACH_CARD_TOPIC = 6; // 话题活动 ATTACH_CARD_OGV = 7; // OGV ATTACH_CARD_AUTO_OGV = 8; // OGV自动出卡 ATTACH_CARD_GAME = 9; // 游戏 ATTACH_CARD_MANGA = 10; // 漫画 ATTACH_CARD_DECORATION = 11; // 装扮 ATTACH_CARD_MATCH = 12; // 赛事 ATTACH_CARD_PUGV = 13; // 课程 ATTACH_CARD_RESERVE = 14; // 预约 ATTACH_CARD_UP_TOPIC = 15; // up主话题活动 } // message BottomBusiness { // 业务方资源id int64 rid = 1; // 业务方类型,定义在BottomBizType中 int64 type = 2; } // enum ContentType { CONTENT_TYPE_NONE = 0; // 占位 TEXT = 1; // 文本,简单内容,biz_id就是文本 AT = 2; // @用户,简单内容,biz_id是用户uid LOTTERY = 3; // 抽奖,简单内容,biz_id是抽奖id VOTE = 4; // 投票,简单内容,biz_id是投票id TOPIC = 5; // 话题,简单内容,biz_id是话题id GOODS = 6; // 商品文字链,复杂内容,定义在GoodsContent结构,biz_id为空 BV = 7; // bv,简单内容,biz_id是bvid,包括"BV1"等内容 AV = 8; // av,简单内容,biz_id是avid EMOJI = 9; // 表情,简单内容,biz_id为空 USER = 10; // 外露用户,暂未使用 CV = 11; // 专栏,简单内容,biz_id是cvid VC = 12; // 废弃业务,无用 WEB = 13; // 网址,简单内容,biz_id是网页链接 TAOBAO = 14; // 淘宝内容,暂时不用 MAIL = 15; // 邮箱,简单内容,biz_id是邮箱地址 OGV_SEASON = 16; // 番剧season,简单内容,biz_id是番剧的season_id OGV_EP = 17; // 番剧ep,简单内容,biz_id是番剧的epid } // message CreateActivity { // int64 activity_id = 1; // int32 activity_state = 2; // int32 is_new_activity = 3; // int32 action = 4; } // 动态附带的附加大卡 message CreateAttachCard { // 商品大卡 CreateGoodsCard goods = 1; // 通用附加大卡,目前仅限定Match,Game,Ugc,Pugv,Reserve,且同时只能有一个 CreateCommonAttachCard common_card = 2; } // 发布页预校验-响应 message CreateCheckResp { // 发布相关的配置项 PublishSetting setting = 1; // 用户具有的发布权限 UpPermission permission = 2; // 分享渠道信息 ShareChannel share_info = 3; // 小黄条 PublishYellowBar yellow_bar = 4; // PlusRedDot plus_red_dot = 5; } // 创建动态时附带的通用附加卡详情 message CreateCommonAttachCard { // 通用附加卡的类型 AttachCardType type = 1; // 通用附加卡的业务id int64 biz_id = 2; // int32 reserve_source = 3; // int32 reserve_lottery = 4; } // 动态-描述文字模块 message CreateContent { // 描述信息(已按高亮拆分) repeated CreateContentItem contents = 1; } // 文本描述 message CreateContentItem { // 原始文案 string raw_text = 1; // 类型 ContentType type = 2; // 简单内容,可能为文字,BVID,AVID,uid等;复杂内容需要单独定义结构体 string biz_id = 3; // 商品内容 GoodsContent goods = 4; } // message CreateDynVideo { // 投稿平台来源,具体写什么@产品 string relation_from = 1; // 1 — 投稿入口 + 相册选择视频 2 — 投稿入口 + 拍摄 3 — 小视频入口 + 相册选择视频 4 — 小视频入口 + 拍摄 int32 biz_from = 3; // 投稿类型: 2-转载、1-自制 int32 copyright = 4; // 是否公开投稿 0允许公开,1不允许公开 默认 0公开 int32 no_public = 5; // 是否允许转载字段 0允许,1不允许,默认为0 copyright = 1 自制的时候默认勾选上no_reprint=1 int32 no_reprint = 6; // 转载的时候必须填写,非空字符串 string source = 7; // 稿件封面必须填写,不能为空 封面不支持其他源站链接 请确保 cover 是 先经过上传接口 string cover = 8; // 稿件标题 string title = 9; // 稿件分区ID 必须是有效的二级分区ID int64 tid = 10; // 标签 多个标签请使用英文逗号连接 string tag = 11; // 稿件描述 string desc = 12; // 当前输入环境下有,就输入http://domain/x/app/archive/desc/format返回的desc_format值 // 如果返回null就输入默认为0, 表示当前环境(分区+投稿类型)不参与简介格式化 int64 desc_format_id = 13; // 稿件是否开启充电面板,1为是, 0为否 int32 open_elec = 14; // 定时发布的时间 int32 dtime = 15; // 分P聚合字段 repeated DynVideoMultiP videos = 16; // 水印信息 DynVideoWatermark watermark = 17; // 新增加通过tag来参加活动 int64 mission_id = 18; // 新增加可以添加动态内容 string dynamic = 19; // 序列化后的extend_info扩展信息 string dynamic_extension = 20; // 客户端控制字段 string dynamic_ctrl = 21; // 动态来源 string dynamic_from = 22; // 抽奖服务生成的ID int64 lottery_id = 23; // DynVideoVote vote = 24; // 精选评论开关, true为开 bool up_selection_reply = 25; // up主关闭评论 bool up_close_reply = 26; // up主关闭弹幕 bool up_close_danmu = 27; // 稿件投稿来源 int64 up_from = 28; // int64 duration = 29; } // 创建动态视频的应答包(透传给客户端) message CreateDynVideoResult { // 稿件id int64 aid = 1; // 说明信息 string message = 2; // 推荐的活动信息 DynVideoSubmitActBanner submitact_banner = 3; // DynVideoPushIntro push_intro = 4; } // 创建动态时附带的商品大卡详情 message CreateGoodsCard { // 商品大卡中的商品id repeated string item_id = 1; } // 发布页预校验场景 enum CreateInitCheckScene { CREATE_INIT_CHECK_SCENE_INVALID = 0; // CREATE_INIT_CHECK_SCENE_NORMAL = 1; // 动态页面右上角点击进入发布页 CREATE_INIT_CHECK_SCENE_REPOST = 2; // 动态feed流转发、三点分享,动态详情页转发 CREATE_INIT_CHECK_SCENE_SHARE = 3; // 其他页面分享到动态 CREATE_INIT_CHECK_SCENE_RESERVE_SHARE = 4; // } // 动态创建时的特殊选项 message CreateOption { // 评论区展示UP自己精选的评论 int32 up_choose_comment = 1; // 初始评论区是关闭状态 int32 close_comment = 2; // 该动态不会被折叠 // 目前仅抽奖开奖动态不会被折叠 int32 fold_exclude = 3; // 审核等级,仅服务端发布时有效 // 100:自动过审 // 非100:默认的内网审核 // 默认为0 int32 audit_level = 4; // 根据转发内容同步生成一条源动态/资源的评论 // 仅转发和分享时有效 int32 sync_to_comment = 5; // VideoShareInfo video_share_info = 6; // CreateActivity activity = 7; } // 创建图文动态时的图片信息 message CreatePic { // 上传图片URL string img_src = 1; // 图片宽度 double img_width = 2; // 图片高度 double img_height = 3; // 图片大小,单位KB double img_size = 4; // repeated CreatePicTag img_tags = 5; } // message CreatePicTag { // int64 item_id = 1; // int64 tid = 2; // int64 mid = 3; // string text = 4; // string text_string = 5; // int64 type = 6; // int64 source_type = 7; // string url = 8; // string schema_url = 9; // string jump_url = 10; // int64 orientation = 11; // int64 x = 12; // int64 y = 13; // string poi = 14; } // 创建动态-响应 message CreateResp { // 动态id int64 dyn_id = 1; // 动态id str string dyn_id_str = 2; // 动态的类型 int64 dyn_type = 3; // 动态id int64 dyn_rid = 4; // 假卡 bilibili.app.dynamic.v2.DynamicItem fake_card = 5; // 视频 CreateDynVideoResult video_result = 6; } // 发布类型(场景) enum CreateScene { CREATE_SCENE_INVALID = 0; // CREATE_SCENE_CREATE_WORD = 1; // 发布纯文字动态 CREATE_SCENE_CREATE_DRAW = 2; // 发布图文动态 CREATE_SCENE_CREATE_DYN_VIDEO = 3; // 发布动态视频 CREATE_SCENE_REPOST = 4; // 转发动态 CREATE_SCENE_SHARE_BIZ = 5; // 分享业务方资源 CREATE_SCENE_SHARE_PAGE = 6; // 分享网页(通用模板) CREATE_SCENE_SHARE_PROGRAM = 7; // 分享小程序 CREATE_SCENE_REPLY_SYNC = 8; // 评论同步到动态 CREATE_SCENE_REPLY_CREATE_ACTIVITY = 9; // 评论同步到动态并且发起活动 } // 动态附带的小卡 message CreateTag { // lbs小卡 ExtLbs lbs = 1; // 游戏通过SDK发布的动态需要带上游戏小卡 BottomBusiness sdk_game = 2; // 必剪发布的动态需要带上必剪小卡 BottomBusiness diversion = 3; } // message CreateTopic { // int64 id = 1; // string name = 2; } // 动态的标识 message DynIdentity { // 动态id int64 dyn_id = 1; // 动态反向id,通过(type+rid组合)也可以唯一标识一个动态,与dyn_id出现任意一个即可 DynRevsId revs_id = 2; } // message DynRevsId { // 动态类型 int64 dyn_type = 1; // 业务id int64 rid = 2; } // 动态视频分P视频编辑环境上报信息 message DynVideoEditor { // int64 cid = 1; // int32 upfrom = 2; // 滤镜 string filters = 3; // 字体 string fonts = 4; // 字幕 string subtitles = 5; // bgm string bgms = 6; // 3d拍摄贴纸 string stickers = 7; // 2d投稿贴纸 string videoup_stickers = 8; // 视频转场特效 string trans = 9; // 编辑器的主题使用相关 string makeups = 10; // 整容之外科手术 string surgerys = 11; // 美摄特定的videofx string videofxs = 12; // 编辑器的主题使用相关 string themes = 13; // 拍摄之稿件合拍 string cooperates = 14; // 拍摄之音乐卡点视频 string rhythms = 15; // mvp特效 string effects = 16; // mvp背景 string backgrounds = 17; // mvp视频 string videos = 18; // mvp音效 string sounds = 19; // mvp花字 string flowers = 20; // mvp封面模板 string cover_templates = 21; // tts string tts = 22; // openings string openings = 23; // 录音题词 bool record_text = 24; // 虚拟形象上报 string vupers = 25; // string features = 26; // string bcut_features = 27; // int32 audio_record = 28; // int32 camera = 29; // int32 speed = 30; // int32 camera_rotate = 31; // int32 screen_record = 32; // int32 default_end = 33; // int32 duration = 34; // int32 pic_count = 35; // int32 video_count = 36; // int32 shot_duration = 37; // string shot_game = 38; // bool highlight = 39; // int32 highlight_cnt = 40; // int32 pip_count = 41; } // message DynVideoHotAct { // int64 act_id = 1; // int64 etime = 2; // int64 id = 3; // string pic = 4; // int64 stime = 5; // string title = 6; // string link = 7; } // 动态视频分P聚合字段 message DynVideoMultiP { // 分P标题 string title = 1; // 分P的文件名 string filename = 2; // int64 cid = 3; // 编辑环境上报信息 DynVideoEditor editor = 4; } // message DynVideoPushIntro { // int32 show = 1; // string text = 2; } // message DynVideoSubmitActBanner { // string hotact_text = 1; // string hotact_url = 2; // repeated DynVideoHotAct list = 3; } // message DynVideoVote { // int64 vote_id = 1; // string vote_title = 2; // int32 top_for_reply = 3; } // 动态视频水印信息 message DynVideoWatermark { // 水印状态 // 0-关闭 1-打开 2-预览 int32 state = 1; // 类型 // 1-用户昵称类型 2-用户id类型 3-用户名在logo下面 int32 type = 2; // 位置 // 1-左上 2-右上 3-左下 4-右下 int32 position = 3; } // message ExtLbs { // string address = 1; // int64 distance = 2; // int64 type = 3; // string poi = 4; // LbsLoc location = 5; // string show_title = 6; // string title = 7; // string show_distance = 8; } // 根据name取uid-请求 message GetUidByNameReq { // 查询昵称列表 repeated string names = 1; } // 根据name取uid-响应 message GetUidByNameRsp { // k:昵称 v:mid map uids = 1; } // 发布时附带的商品卡的详细内容 message GoodsContent { // 商品类型 // 1淘宝、2会员购 int32 source_type = 1; // 商品的id int64 item_id = 2; // 店铺的id,兼容老版本 int64 shop_id = 3; } // UP已经创建的活动列表 message LaunchedActivity { // 模块名称,示例:"已创建的活动" string module_title = 1; // 已创建的活动列表 repeated LaunchedActivityItem activities = 2; // 展示更多按钮 // 已创建活动大于5个时下发 ShowMoreLaunchedActivity show_more = 3; } // UP已经创建的活动详情 message LaunchedActivityItem { // 活动id int64 activity_id = 1; // 活动名称 string activity_name = 2; // 活动是否已上线 // 0未上线 1已上线 int32 activity_state = 3; } // message LbsLoc { // 经度 double lat = 1; // 纬度 double lng = 2; } // message MetaDataCtrl { // 客户端平台 string platform = 1; // 客户端build号 string build = 2; // 客户端移动设备类型 string mobi_app = 3; // 客户端buvid string buvid = 4; // 用户设备信息 string device = 5; // 请求来源页面的spmid string from_spmid = 6; // 请求来源页面 string from = 7; // 请求的trace_id string trace_id = 8; // 青少年模式 int32 teenager_mode = 9; // 0:正常 1:冷启动 int32 cold_start = 10; // 客户端版本号 string version = 11; // 网络状态 // Unknown=0 WIFI=1 WWAN=2 int32 network = 12; // 用户ip地址 string ip = 13; } // message PlusRedDot { // int64 plus_has_red_dot = 1; } // 小程序内容定义 message Program { // 标题 string title = 1; // 描述文字 string desc = 2; // 封面图 string cover = 3; // 跳转链接 string target_url = 4; // 小程序icon string icon = 5; // 小程序名称 string program_text = 6; // 跳转链接文案,如:去看看 string jump_text = 7; } // 发布相关的设置项 message PublishSetting { // 提示转为专栏的最小字数,使用utf-16编码计算字符数 int32 min_words_to_article = 1; // 提示转为专栏的最大字数,使用utf-16编码计算字符数 int32 max_words_to_article = 2; // gif上传的最大值,单位:MB int32 upload_size = 3; } // 发布页小黄条 message PublishYellowBar { // 展示文案 string text = 1; // 跳转链接 string url = 2; // 展示图标 string icon = 3; } // message RepostInitCheck { // DynIdentity repost_src = 1; // string share_id = 2; // int32 share_mode = 3; } // enum ReserveSource { RESERVE_SOURCE_NEW = 0; // RESERVE_SOURCE_ASSOCIATED = 1; // } // 分享渠道信息 message ShareChannel { // 业务类型,如动态是"dynamic" string share_origin = 1; // 业务资源id string oid = 2; // 辅助id, 非必返回字段 string sid = 3; // 渠道列表 repeated ShareChannelItem share_channels = 4; } // 渠道 message ShareChannelItem { // 展示文案 string name = 1; // 展示图标 string picture = 2; // 渠道名称 string share_channel = 3; // 预约卡分享图信息,仅分享有预约信息的动态时存在 ShareReserve reserve = 4; } // message ShareReserve { // 标题 string title = 1; // 描述(时间+类型) string desc = 2; // 二维码附带icon string qr_code_icon = 3; // 二维码附带文本 string qr_code_text = 4; // 二维码链接 string qr_code_url = 5; // string name = 6; // string face = 7; // ShareReservePoster poster = 8; // ShareReserveLottery reserve_lottery = 9; } // message ShareReserveLottery { // string icon = 1; // string text = 2; } // message ShareReservePoster { // string url = 1; // double width = 2; // double height = 3; } // message ShareResult { // int64 share_enable = 1; // string toast = 2; } // UP已经创建的活动列表中的展示更多按钮详情 message ShowMoreLaunchedActivity { // 按钮的文案 string button_text = 1; // 按钮的跳转链接 string jump_url = 2; } // 通用模板的网页元内容(sketch结构)定义 message Sketch { // 元内容标题,长度30限制 string title = 1; // 描述文字(文本内容第二行),长度233限制 string desc_text = 2; // 文本文字(文本内容第三行),仅限竖图通用卡片使用,长度233限制 string text = 3; // 表示业务方的id表示,对于在业务方有唯一标示的必填 int64 biz_id = 4; // 业务类型,与展示时的右上角标有关,需要业务方向动态申请 int64 biz_type = 5; // 封面图片链接地址,域名需要符合白名单 string cover_url = 6; // 跳转链接地址,域名需要符合白名单 string target_url = 7; } // 发布相关的权限内容 message UpPermission { // 通用权限列表 repeated UpPermissionItem items = 1; // 已经创建的活动列表 LaunchedActivity launched_activity = 2; // ShareResult share_result = 3; } // 通用发布权限内容的详细定义 message UpPermissionItem { // 类型,enum UpPermissionType int32 type = 1; // UP是否有权限 // 1-有,2-限制(展示但不可点,仅预约使用) int32 permission = 2; // 按钮文案 string title = 3; // 功能开关的副标题 string subtitle = 4; // 按钮图标的url地址 string icon = 5; // 跳转链接,permission=1时点击按钮跳到此链接 string jump_url = 6; // 错误提示,permission=2时点击按钮会弹出此提示,目前仅预约使用 string toast = 7; // int64 has_red_dot = 8; } // enum UpPermissionType { UP_PERMISSION_TYPE_NONE = 0; // 占位 UP_PERMISSION_TYPE_LOTTERY = 1; // 是否是抽奖的灰度用户,默认不是 UP_PERMISSION_TYPE_CLIP_PUBLISHED = 2; // 之前是否发过小视频,默认没发过 UP_PERMISSION_TYPE_UGC_ATTACH_CARD = 3; // 是否可以添加ugc附加卡,默认不可以 UP_PERMISSION_TYPE_GOODS_ATTACH_CARD = 4; // 是否有权限添加商品附加卡 UP_PERMISSION_TYPE_CHOOSE_COMMENT = 5; // 是否有权限自主精选评论白名单,默认没有 UP_PERMISSION_TYPE_CONTROL_COMMENT = 6; // 是否有权限关闭评论区,默认有 UP_PERMISSION_TYPE_CONTROL_DANMU = 7; // 是否有权限关闭弹幕(仅对动态视频生效),默认有 UP_PERMISSION_TYPE_VIDEO_RESERVE = 8; // 是否可以发起稿件预约 UP_PERMISSION_TYPE_LIVE_RESERVE = 9; // 是否可以发起直播预约 } // 用户主动发布(app/web发布)时的meta信息 message UserCreateMeta { // 用户发布客户端的meta信息 MetaDataCtrl app_meta = 1; // 用户发布时的位置信息(经纬度) LbsLoc loc = 2; // 1-发布页转发 2-立即转发 int32 repost_mode = 3; } // message VideoShareInfo { // int64 cid = 1; // int32 part = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/dynamic/gw/gateway.proto ================================================ syntax = "proto3"; package bilibili.dynamic.gateway; option java_multiple_files = true; import "google/protobuf/any.proto"; // 综合页请求广告所需字段,由客户端-网关透传 message AdParam { // 综合页请求广告所需字段,由客户端-网关透传 string ad_extra = 1; // request_id string request_id = 2; } // enum AddButtonBgStyle { fill = 0; // 默认填充 stroke = 1; // 描边 gray = 2; // 置灰 } // 按钮类型 enum AddButtonType { bt_none = 0; // 占位 bt_jump = 1; // 跳转 bt_button = 2; // 按钮 } // 动态-附加卡-通用卡 message AdditionCommon { // 头部说明文案 string head_text = 1; // 标题 string title = 2; // 展示图 string image_url = 3; // 描述文字1 string desc_text_1 = 4; // 描述文字2 string desc_text_2 = 5; // 点击跳转链接 string url = 6; // 按钮 AdditionalButton button = 7; // 头部icon string head_icon = 8; // style ImageStyle style = 9; // 动态本身的类型 type string type = 10; // 附加卡类型 string card_type = 11; // ogv manga } // 动态-附加卡-电竞卡 message AdditionEsport { // 电竞类型 EspaceStyle style = 1; oneof item { // moba类 AdditionEsportMoba addition_esport_moba = 2; } // 动态本身的类型 type string type = 3; // 附加卡类型 string card_type = 4; // ogv manga } // 动态-附加卡-电竞卡-moba类 message AdditionEsportMoba { // 头部说明文案 string head_text = 1; // 标题 string title = 2; // 战队列表 repeated MatchTeam match_team = 3; // 比赛信息 AdditionEsportMobaStatus addition_esport_moba_status = 4; // 卡片跳转 string uri = 5; // 按钮 AdditionalButton button = 6; // 副标题 string sub_title = 7; // 动态本身的类型 type string type = 10; // 附加卡类型 string card_type = 11; } // 动态-附加卡-电竞卡-moba类-比赛信息 message AdditionEsportMobaStatus { // 文案类 repeated AdditionEsportMobaStatusDesc addition_esport_moba_status_desc = 1; // 比赛状态文案 string title = 2; // 比赛状态状态 int32 status = 3; // 日间色值 string color = 4; // 夜间色值 string night_color = 5; } // 动态-附加卡-电竞卡-moba类-比赛信息-文案类 message AdditionEsportMobaStatusDesc { // 文案 string title = 1; // 日间色值 string color = 2; // 夜间色值 string night_color = 3; } // 动态-附加卡-商品卡 message AdditionGoods { // 推荐文案 string rcmd_desc = 1; // 商品信息 repeated GoodsItem goods_items = 2; // 附加卡类型 string card_type = 3; // 头部icon string icon = 4; // 商品附加卡整卡跳转 string uri = 5; // 商品类型 // 1:淘宝 2:会员购,注:实际是获取的goods_items里面的第一个source_type int32 source_type = 6; } // up主预约发布卡 message AdditionUP { // 标题 string title = 1; // 高亮文本,描述文字1 HighlightText desc_text_1 = 2; // 描述文字2 string desc_text_2 = 3; // 点击跳转链接 string url = 4; // 按钮 AdditionalButton button = 5; // 附加卡类型 string card_type = 6; // 预约人数(用于预约人数变化) int64 reserve_total = 7; // 活动皮肤 AdditionalActSkin act_skin = 8; } // 动态-附加卡-UGC视频附加卡 message AdditionUgc { // 说明文案 string head_text = 1; // 稿件标题 string title = 2; // 封面 string cover = 3; // 描述文字1 string desc_text_1 = 4; // 描述文字2 string desc_text_2 = 5; // 接秒开 string uri = 6; // 时长 string duration = 7; // 标题支持换行-标题支持单行和双行,本期不支持填充up昵称,支持双行展示,字段默认为true bool line_feed = 8; // 附加卡类型 string card_type = 9; } // 动态-附加卡-投票 message AdditionVote { // 封面图 string image_url = 1; // 标题 string title = 2; // 展示项1 string text_1 = 3; // button文案 string button_text = 4; // 点击跳转链接 string url = 5; } // 动态模块-投票 message AdditionVote2 { // 投票类型 AdditionVoteType addition_vote_type = 1; // 投票ID int64 vote_id = 2; // 标题 string title = 3; // 已过期: xxx人参与· 投票已过期。button 展示去查看 // 未过期: xxx人参与· 剩xx天xx时xx分。button展示去投票 string label = 4; // 剩余时间 int64 deadline = 5; // 生效文案 string open_text = 6; // 过期文案 string close_text = 7; // 已投票 string voted_text = 8; // 投票状态 AdditionVoteState state = 9; // 投票信息 oneof item { // AdditionVoteWord addition_vote_word = 10; // AdditionVotePic addition_vote_pic = 11; // AdditionVoteDefaule addition_vote_defaule = 12; } // 业务类型 // 0:动态投票 1:话题h5组件 int32 biz_type = 13; // 投票总人数 int64 total = 14; // 附加卡类型 string card_type = 15; // 异常提示 string tips = 16; // 跳转地址 string uri = 17; // 是否投票 bool is_voted = 18; // 投票最多多选个数,单选为1 int32 choice_cnt = 19; // 是否默认选中分享到动态 bool defaule_select_share = 20; } // 外露投票 message AdditionVoteDefaule { // 图片 多张 repeated string cover = 1; } // 外露图片类型 message AdditionVotePic { // 图片投票详情 repeated AdditionVotePicItem item = 1; } // 图片投票详情 message AdditionVotePicItem { // 选项索引,从1开始 int32 opt_idx = 1; // 图片 string cover = 2; // 选中状态 bool is_vote = 3; // 人数 int32 total = 4; // 占比 double persent = 5; // 标题文案 string title = 6; // 是否投票人数最多的选项 bool is_max_option = 7; } // 投票状态 enum AdditionVoteState { addition_vote_state_none = 0; // addition_vote_state_open = 1; // addition_vote_state_close = 2; // } // 投票类型 enum AdditionVoteType { addition_vote_type_none = 0; // addition_vote_type_word = 1; // addition_vote_type_pic = 2; // addition_vote_type_default = 3; // } // 外露文字类型 message AdditionVoteWord { // 外露文字投票详情 repeated AdditionVoteWordItem item = 1; } // 外露文字投票详情 message AdditionVoteWordItem { // 选项索引,从1开始 int32 opt_idx = 1; // 文案 string title = 2; // 选中状态 bool is_vote = 3; // 人数 int32 total = 4; // 占比 double persent = 5; // 是否投票人数最多的选项 bool is_max_option = 6; } // 活动皮肤 message AdditionalActSkin { // 动画SVGA资源 string svga = 1; // 动画SVGA最后一帧图片资源 string last_image = 2; // 动画播放次数 int64 play_times = 3; } // 动态-附加卡-按钮 message AdditionalButton { // 按钮类型 AddButtonType type = 1; // jump-跳转样式 AdditionalButtonStyle jump_style = 2; // jump-跳转链接 string jump_url = 3; // button-未点样式 AdditionalButtonStyle uncheck = 4; // button-已点样式 AdditionalButtonStyle check = 5; // button-当前状态 AdditionalButtonStatus status = 6; // 按钮点击样式 AdditionalButtonClickType click_type = 7; } // 附加卡按钮点击类型 enum AdditionalButtonClickType { click_none = 0; // 通用按钮 click_up = 1; // 预约卡按钮 } message AdditionalButtonInteractive { // 是否弹窗 string popups = 1; // 弹窗确认文案 string confirm = 2; // 弹窗取消文案 string cancel = 3; // string desc = 4; } // enum AdditionalButtonStatus { none = 0; // uncheck = 1; // check = 2; // } // 动态-附加卡-按钮样式 message AdditionalButtonStyle { // icon string icon = 1; // 文案 string text = 2; // 按钮点击交互 AdditionalButtonInteractive interactive = 3; // 当前按钮填充样式 AddButtonBgStyle bg_style = 4; // toast文案, 当disable=1时有效 string toast = 5; // 当前按钮样式, // 0:高亮 1:置灰(按钮不可点击) DisableState disable = 6; } // 动态-附加卡-番剧卡 message AdditionalPGC { // 头部说明文案 string head_text = 1; // 标题 string title = 2; // 展示图 string image_url = 3; // 描述文字1 string desc_text_1 = 4; // 描述文字2 string desc_text_2 = 5; // 点击跳转链接 string url = 6; // 按钮 AdditionalButton button = 7; // 头部icon string head_icon = 8; // style ImageStyle style = 9; // 动态本身的类型 type string type = 10; } // 枚举-动态附加卡 enum AdditionalType { additional_none = 0; // 占位 additional_type_pgc = 1; // 附加卡-追番 additional_type_goods = 2; // 附加卡-商品 additional_type_vote = 3; // 附加卡投票 additional_type_common = 4; // 附加通用卡 additional_type_esport = 5; // 附加电竞卡 additional_type_up_rcmd = 6; // 附加UP主推荐卡 additional_type_ugc = 7; // 附加卡-ugc additional_type_up_reservation = 8; // UP主预约卡 } // 动态卡片列表 message CardVideoDynList { // 动态列表 repeated DynamicItem list = 1; // 更新的动态数 int64 update_num = 2; // 历史偏移 string history_offset = 3; // 更新基础信息 string update_baseline = 4; // 是否还有更多数据 bool has_more = 5; } // 视频页-我的追番 message CardVideoFollowList { // 查看全部(跳转链接) string view_all_link = 1; // 追番列表 repeated FollowListItem list = 2; } // 视频页-最近访问 message CardVideoUpList { // 标题展示文案 string title = 1; // up主列表 repeated UpListItem list = 2; // 服务端生成的透传上报字段 string footprint = 3; // 直播数 int32 show_live_num = 4; // 跳转label UpListMoreLabel more_label = 5; // 标题开关(综合页) int32 title_switch = 6; // 是否展示右上角查看更多label bool show_more_label = 7; // 是否在快速消费页查看更多按钮 bool show_in_personal = 8; // 是否展示右侧查看更多按钮 bool show_more_button = 9; } // 评论外露展示项 message CmtShowItem { // 用户mid int64 uid = 1; // 用户昵称 string uname = 2; // 点击跳转链接 string uri = 3; // 评论内容 string comment = 4; } // 装扮卡片-粉丝勋章信息 message DecoCardFan { // 是否是粉丝 int32 is_fan = 1; // 数量 int32 number = 2; // 数量 str string number_str = 3; // 颜色 string color = 4; } // 装扮卡片 message DecorateCard { // 装扮卡片id int64 id = 1; // 装扮卡片链接 string card_url = 2; // 装扮卡片点击跳转链接 string jump_url = 3; // 粉丝样式 DecoCardFan fan = 4; } // 文本类型 enum DescType { desc_type_none = 0; // 占位 desc_type_text = 1; // 文本 desc_type_aite = 2; // @ desc_type_lottery = 3; // 抽奖 desc_type_vote = 4; // 投票 desc_type_topic = 5; // 话题 desc_type_goods = 6; // 商品 desc_type_bv = 7; // bv desc_type_av = 8; // av desc_type_emoji = 9; // 表情 desc_type_user = 10; // 外露用户 desc_type_cv = 11; // 专栏 desc_type_vc = 12; // 小视频 desc_type_web = 13; // 网址 desc_type_taobao = 14; // 淘宝 desc_type_mail = 15; // 邮箱 desc_type_ogv_season = 16; // 番剧season desc_type_ogv_ep = 17; // 番剧ep } // 文本描述 message Description { // 文本内容 string text = 1; // 文本类型 DescType type = 2; // 点击跳转链接 string uri = 3; // emoji类型 EmojiType emoji_type = 4; // 商品类型 string goods_type = 5; // 前置Icon string icon_url = 6; // icon_name string icon_name = 7; // 资源ID string rid = 8; // 商品卡特殊字段 ModuleDescGoods goods = 9; // 文本原始文案 string orig_text = 10; } // 尺寸信息 message Dimension { // int64 height = 1; // int64 width = 2; // int64 rotate = 3; } // enum DisableState { highlight = 0; // 高亮 gary = 1; // 置灰(按钮不可点击) } // 动态通用附加卡-follow/取消follow-响应 message DynAdditionCommonFollowReply { // AdditionalButtonStatus status = 1; } // 动态通用附加卡-follow/取消follow-请求 message DynAdditionCommonFollowReq { // AdditionalButtonStatus status = 1; // string dyn_id = 2; // string card_type = 3; } // 最近访问-个人feed流列表-返回 message DynAllPersonalReply { // 动态列表 repeated DynamicItem list = 1; // 偏移量 string offset = 2; // 是否还有更多数据 bool has_more = 3; // 已读进度 string read_offset = 4; // 关注状态 Relation relation = 5; } // 最近访问-个人feed流列表-请求 message DynAllPersonalReq { // 被访问者的 UID int64 host_uid = 1; // 偏移量 第一页可传空 string offset = 2; // 标明下拉几次 int32 page = 3; // 是否是预加载 默认是1;客户端预加载。1:是预加载,不更新已读进度,不会影响小红点;0:非预加载,更新已读进度 int32 is_preload = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 6; // 服务端生成的透传上报字段 string footprint = 7; // 来源 string from = 8; // 秒开用 PlayerArgs player_args = 9; } // 动态综合页-响应 message DynAllReply { // 卡片列表 DynamicList dynamic_list = 1; // 顶部up list CardVideoUpList up_list = 2; // 话题广场 TopicList topic_list = 3; // 无关注推荐 Unfollow unfollow = 4; } // 动态综合页-请求 message DynAllReq { // 透传 update_baseline string update_baseline = 1; // 透传 history_offset string offset = 2; // 向下翻页数 int32 page = 3; // 刷新方式 1向上刷新 2向下翻页 Refresh refresh_type = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 综合页当前更新的最大值 string assist_baseline = 6; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 7; // 推荐up主入参(new的时候传) RcmdUPsParam rcmd_ups_param = 8; // 广告参数 AdParam ad_param = 9; // 是否冷启 int32 cold_start = 10; // 来源 string from = 11; // 秒开参数 PlayerArgs player_args = 12; } // 最近访问-标记已读-请求 message DynAllUpdOffsetReq { // 被访问者的UID int64 host_uid = 1; // 用户已读进度 string read_offset = 2; // 服务端生成的透传上报字段 string footprint = 3; } // 动态详情页-响应 message DynDetailReply { // 动态详情 DynamicItem item = 1; } // 批量动态id获取动态详情-请求 message DynDetailsReq { // 动态id string dynamic_ids = 1; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 2; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 3; // 秒开参数 PlayerArgs player_args = 4; } // 动态小卡类型 enum DynExtendType { dyn_ext_type_none = 0; // 占位 dyn_ext_type_topic = 1; // 话题小卡 dyn_ext_type_lbs = 2; // lbs小卡 dyn_ext_type_hot = 3; // 热门小卡 dyn_ext_type_game = 4; // 游戏小卡 dyn_ext_type_common = 5; // 通用小卡 dyn_ext_type_biliCut = 6; // 必剪小卡 dyn_ext_type_ogv = 7; // ogv小卡 dyn_ext_type_auto_ogv = 8; // 自动附加ogv小卡 } // 动态发布生成临时卡-响应 message DynFakeCardReply { // 动态卡片 DynamicItem item = 1; } // 动态发布生成临时卡-请求 message DynFakeCardReq { //卡片内容json string string content = 1; } // 查看更多-列表-响应 message DynMixUpListViewMoreReply { // repeated MixUpListItem items = 1; // string search_default_text = 2; // 排序类型列表 repeated SortType sort_types = 3; // 是否展示更多的排序策略 bool show_more_sort_types = 4; // 默认排序策略 int32 default_sort_type = 5; } // 查看更多-请求 message DynMixUpListViewMoreReq { // 排序策略 // 1:推荐排序 2:最常访问 3:最近关注,其他值为默认排序 int32 sort_type = 1; } // 动态模块类型 enum DynModuleType { module_none = 0; // 占位 module_author = 1; // 发布人模块 module_dispute = 2; // 争议小黄条 module_desc = 3; // 描述文案 module_dynamic = 4; // 动态卡片 module_forward = 5; // 转发模块 module_likeUser = 6; // 点赞用户(废弃) module_extend = 7; // 小卡模块 module_additional = 8; // 附加卡 module_stat = 9; // 计数信息 module_fold = 10; // 折叠 module_comment = 11; // 评论外露(废弃) module_interaction = 12; // 外露交互模块(点赞、评论) module_author_forward = 13; // 转发卡的发布人模块 module_ad = 14; // 广告卡模块 module_banner = 15; // 通栏模块 module_item_null = 16; // 获取物料失败模块 module_share_info = 17; // 分享组件 module_recommend = 18; // 相关推荐模块 module_stat_forward = 19; // 转发卡计数信息 module_top = 20; // 顶部模块 module_bottom = 21; // 底部模块 } // 关注推荐up主换一换-响应 message DynRcmdUpExchangeReply { // 无关注推荐 Unfollow unfollow = 1; } // 关注推荐up主换一换-请求 message DynRcmdUpExchangeReq { // 登录用户id int64 uid = 1; // 上一次不感兴趣的ts,单位:秒;该字段透传给搜索 int64 dislikeTs = 2; // 需要与服务端确认或参照客户端现有参数 string from = 3; } // 动态点赞-请求 message DynThumbReq { // 用户uid int64 uid = 1; // 动态id string dyn_id = 2; // 动态类型(透传extend中的dyn_type) int64 dyn_type = 3; // 业务方资源id string rid = 4; // 点赞类型 ThumbType type = 5; } // enum DynUriType { dyn_uri_type_none = 0; // dyn_uri_type_direct = 1; // 直接跳转对应uri dyn_uri_type_suffix = 2; // 作为后缀拼接 } // 最近访问-个人feed流列表-响应 message DynVideoPersonalReply { // 动态列表 repeated DynamicItem list = 1; // 偏移量 string offset = 2; // 是否还有更多数据 bool has_more = 3; // 已读进度 string read_offset = 4; // 关注状态 Relation relation = 5; } // 最近访问-个人feed流列表-请求 message DynVideoPersonalReq { // 被访问者的 UID int64 host_uid = 1; // 偏移量 第一页可传空 string offset = 2; // 标明下拉几次 int32 page = 3; // 是否是预加载 int32 is_preload = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 6; // 服务端生成的透传上报字段 string footprint = 7; // 来源 string from = 8; // 秒开参数 PlayerArgs player_args = 9; } // 动态视频页-响应 message DynVideoReply { // 卡片列表 CardVideoDynList dynamic_list = 1; // 动态卡片 CardVideoUpList video_up_list = 2; // 视频页-我的追番 CardVideoFollowList video_follow_list = 3; } // 动态视频页-请求 message DynVideoReq { // 透传 update_baseline string update_baseline = 1; // 透传 history_offset string offset = 2; // 向下翻页数 int32 page = 3; // 刷新方式 // 1:向上刷新 2:向下翻页 Refresh refresh_type = 4; // 秒开参数 新版本废弃,统一使用player_args PlayurlParam playurl_param = 5; // 综合页当前更新的最大值 string assist_baseline = 6; // 客户端时区 兼容UTC-14和Etc/GMT+12,时区区间[-12,14] 东八区为8 int32 local_time = 7; // 来源 string from = 8; // 秒开参数 PlayerArgs player_args = 9; } // 最近访问-标记已读-请求 message DynVideoUpdOffsetReq { // 被访问者的UID int64 host_uid = 1; // 用户已读进度 string read_offset = 2; // 服务端生成的透传上报字段 string footprint = 3; } // 投票操作-响应 message DynVoteReply { // 投票详情 AdditionVote2 item = 1; // 投票操作返回状态 string toast = 2; } // 投票操作-请求 message DynVoteReq { // 投票ID int64 vote_id = 1; // 选项索引数组 repeated int64 votes = 2; // 状态 VoteStatus status = 3; // 动态ID string dynamic_id = 4; // 是否分享 bool share = 5; } // 动态卡片 message DynamicItem { // 动态卡片类型 DynamicType card_type = 1; // 转发类型下,源卡片类型 DynamicType item_type = 2; // 模块内容 repeated Module modules = 3; // 操作相关字段 Extend extend = 4; // 该卡片下面是否含有折叠卡 int32 has_fold = 5; } //动态卡片列表 message DynamicList { // 动态列表 repeated DynamicItem list = 1; // 更新的动态数 int64 update_num = 2; // 历史偏移 string history_offset = 3; // 更新基础信息 string update_baseline = 4; // 是否还有更多数据 bool has_more = 5; } // 枚举-动态类型 enum DynamicType { dyn_none = 0; // 占位 forward = 1; // 转发 av = 2; // 稿件: ugc、小视频、短视频、UGC转PGC pgc = 3; // pgc:番剧、PGC番剧、PGC电影、PGC电视剧、PGC国创、PGC纪录片 courses = 4; // 付费更新批次 fold = 5; // 折叠 word = 6; // 纯文字 draw = 7; // 图文 article = 8; // 专栏 原仅phone端 music = 9; // 音频 原仅phone端 common_square = 10; // 通用卡 方形 common_vertical = 11; // 通用卡 竖形 live = 12; // 直播卡 只有转发态 medialist = 13; // 播单 原仅phone端 只有转发态 courses_season = 14; // 付费更新批次 只有转发态 ad = 15; // 广告卡 applet = 16; // 小程序卡 subscription = 17; // 订阅卡 live_rcmd = 18; // 直播推荐卡 banner = 19; // 通栏 ugc_season = 20; // 合集卡 subscription_new = 21; // 新订阅卡 } // 表情包类型 enum EmojiType { emoji_none = 0; // 占位 emoji_old = 1; // emoji旧类型 emoji_new = 2; // emoji新类型 vip = 3; // 大会员表情 } // 附加大卡-电竞卡样式 enum EspaceStyle { moba = 0; // moba类 } // 动态-拓展小卡模块-通用小卡 message ExtInfoCommon { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // poiType int32 poi_type = 4; // 类型 DynExtendType type = 5; // 客户端埋点用 string sub_module = 6; // 行动点文案 string action_text = 7; // 行动点链接 string action_url = 8; // 资源rid int64 rid = 9; // 轻浏览是否展示 bool is_show_light = 10; } // 动态-拓展小卡模块-游戏小卡 message ExtInfoGame { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 动态-拓展小卡模块-热门小卡 message ExtInfoHot { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 动态-拓展小卡模块-lbs小卡 message ExtInfoLBS { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // poiType int32 poi_type = 4; } // 动态-拓展小卡模块-ogv小卡 message ExtInfoOGV { // ogv小卡 repeated InfoOGV info_ogv = 1; } // 动态-拓展小卡模块-话题小卡 message ExtInfoTopic { // 标题-话题名 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; } // 扩展字段,用于动态部分操作使用 message Extend { // 动态id string dyn_id_str = 1; // 业务方id string business_id = 2; // 源动态id string orig_dyn_id_str = 3; // 转发卡:用户名 string orig_name = 4; // 转发卡:图片url string orig_img_url = 5; // 转发卡:文字内容 repeated Description orig_desc = 6; // 填充文字内容 repeated Description desc = 7; // 被转发的源动态类型 DynamicType orig_dyn_type = 8; // 分享到站外展示类型 string share_type = 9; // 分享的场景 string share_scene = 10; // 是否快速转发 bool is_fast_share = 11; // r_type 分享和转发 int32 r_type = 12; // 数据源的动态类型 int64 dyn_type = 13; // 用户id int64 uid = 14; // 卡片跳转 string card_url = 15; // 透传字段 google.protobuf.Any source_content = 16; // 转发卡:用户头像 string orig_face = 17; // 评论跳转 ExtendReply reply = 18; } // 评论扩展 message ExtendReply { // 基础跳转地址 string uri = 1; // 参数部分 repeated ExtendReplyParam params = 2; } // 评论扩展参数部分 message ExtendReplyParam { // 参数名 string key = 1; // 参数值 string value = 2; } // 折叠类型 enum FoldType { FoldTypeZore = 0; // 占位 FoldTypePublish = 1; // 用户发布折叠 FoldTypeFrequent = 2; // 转发超频折叠 FoldTypeUnite = 3; // 联合投稿折叠 FoldTypeLimit = 4; // 动态受限折叠 } // 视频页-我的追番-番剧信息 message FollowListItem { // season_id int64 season_id = 1; // 标题 string title = 2; // 封面图 string cover = 3; // 跳转链接 string url = 4; // new_ep NewEP new_ep = 5; // 子标题 string sub_title = 6; // 卡片位次 int64 pos = 7; } // enum FollowType { ft_not_follow = 0; // ft_follow = 1; // } // 动态-附加卡-商品卡-商品 message GoodsItem { // 图片 string cover = 1; // schemaPackageName(Android用) string schema_package_name = 2; // 商品类型 // 1:淘宝 2:会员购 int32 source_type = 3; // 跳转链接 string jump_url = 4; // 跳转文案 string jump_desc = 5; // 标题 string title = 6; // 摘要 string brief = 7; // 价格 string price = 8; // item_id int64 item_id = 9; // schema_url string schema_url = 10; // open_white_list repeated string open_white_list = 11; // use_web_v2 bool user_web_v2 = 12; // ad mark string ad_mark = 13; } // 高亮文本 message HighlightText { // 展示文本 string text = 1; // 高亮类型 HighlightTextStyle text_style = 2; } // 文本高亮枚举 enum HighlightTextStyle { style_none = 0; // 默认 style_highlight = 1; // 高亮 } // 枚举-附加卡样式 enum ImageStyle { add_style_vertical = 0; // add_style_square = 1; // } // 动态-拓展小卡模块-ogv小卡-(one of 片单、榜单、分区) message InfoOGV { // 标题 string title = 1; // 跳转地址 string uri = 2; // 小图标 string icon = 3; // 客户端埋点用 string sub_module = 4; } // 外露交互模块 message InteractionItem { // 外露模块类型 LocalIconType icon_type = 1; // 外露模块文案 repeated Description desc = 2; // 外露模块uri相关 根据type不同用法不同 string uri = 3; // 动态id string dynamic_id = 4; // 评论mid int64 comment_mid = 6; } // 点赞动画 message LikeAnimation { // 开始动画 string begin = 1; // 过程动画 string proc = 2; // 结束动画 string end = 3; // id int64 like_icon_id = 4; } // 点赞拓展信息 message LikeInfo { // 点赞动画 LikeAnimation animation = 1; // 是否点赞 bool is_like = 2; } // 点赞用户 message LikeUser { // 用户mid int64 uid = 1; // 用户昵称 string uname = 2; // 点击跳转链接 string uri = 3; } // 直播信息 message LiveInfo { // 是否在直播 // 0:未直播 1:正在直播 (废弃) int32 is_living = 1; // 跳转链接 string uri = 2; // 直播状态 LiveState live_state = 3; } // 直播状态 enum LiveState { live_none = 0; // 未直播 live_live = 1; // 直播中 live_rotation = 2; // 轮播中 } // 外露模块类型 enum LocalIconType { local_icon_comment = 0; // local_icon_like = 1; // } // 动态-附加卡-电竞卡-战队 message MatchTeam { // 战队ID int64 id = 1; // 战队名 string name = 2; // 战队图标 string cover = 3; // 日间色值 string color = 4; // 夜间色值 string night_color = 5; } // 动态列表渲染部分-详情模块-小程序/小游戏 message MdlDynApplet { // 小程序id int64 id = 1; // 跳转地址 string uri = 2; // 主标题 string title = 4; // 副标题 string sub_title = 5; // 封面图 string cover = 6; // 小程序icon string icon = 7; // 小程序标题 string label = 8; // 按钮文案 string button_title = 9; } // 动态-详情模块-稿件 message MdlDynArchive { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // avid int64 avid = 7; // cid int64 cid = 8; // 视频源类型 MediaType media_type = 9; // 尺寸信息 Dimension dimension = 10; // 角标,多个角标之前有间距 repeated VideoBadge badge = 11; // 是否能够自动播放 bool can_play = 12; // stype VideoType stype = 13; // 是否PGC bool isPGC = 14; // inline播放地址 string inlineURL = 15; // PGC的epid int64 EpisodeId = 16; // 子类型 int32 SubType = 17; // PGC的ssid int64 PgcSeasonId = 18; // 播放按钮 string play_icon = 19; // 时长 int64 duration = 20; // 跳转地址 string jump_url = 21; // 番剧是否为预览视频 bool is_preview = 22; // 新角标,多个角标之前没有间距 repeated VideoBadge badge_category = 23; // 当前是否是pgc正片 bool is_feature = 24; // 是否是预约召回 ReserveType reserve_type = 25; // bvid string bvid = 26; // 播放数 int64 view = 27; } // 动态列表渲染部分-详情模块-专栏模块 message MdlDynArticle { // 专栏id int64 id = 1; // 跳转地址 string uri = 2; // 标题 string title = 3; // 文案部分 string desc = 4; // 配图 repeated string covers = 5; // 阅读量标签 string label = 6; // 模板类型 int32 templateID = 7; } // 动态列表渲染部分-详情模块-通用 message MdlDynCommon { // 物料id int64 oid = 1; // 跳转地址 string uri = 2; // 标题 string title = 3; // 描述 漫画卡标题下第一行 string desc = 4; // 封面 string cover = 5; // 标签1 漫画卡标题下第二行 string label = 6; // 所属业务类型 int32 bizType = 7; // 镜像数据ID int64 sketchID = 8; // 卡片样式 MdlDynCommonType style = 9; // 角标 repeated VideoBadge badge = 10; } // enum MdlDynCommonType { mdl_dyn_common_none = 0; // mdl_dyn_common_square = 1; // mdl_dyn_common_vertica = 2; // } // 动态-详情模块-付费课程批次 message MdlDynCourBatch { // 标题 string title = 1; // 封面图 string cover = 2; // 跳转地址 string uri = 3; // 展示项 1(本集标题) string text_1 = 4; // 展示项 2(更新了多少个视频) string text_2 = 5; // 角标 VideoBadge badge = 6; // 播放按钮 string play_icon = 7; } // 动态-详情模块-付费课程系列 message MdlDynCourSeason { // 标题 string title = 1; // 封面图 string cover = 2; // 跳转地址 string uri = 3; // 展示项 1(更新信息) string text_1 = 4; // 描述信息 string desc = 5; // 角标 VideoBadge badge = 6; // 播放按钮 string play_icon = 7; } // 动态列表渲染部分-详情模块-图文模块 message MdlDynDraw { // 图片 repeated MdlDynDrawItem items = 1; // 跳转地址 string uri = 2; // 图文ID int64 id = 3; } // 动态列表渲染部分-详情模块-图文 message MdlDynDrawItem { // 图片链接 string src = 1; // 图片宽度 int64 width = 2; // 图片高度 int64 height = 3; // 图片大小 float size = 4; // 图片标签 repeated MdlDynDrawTag tags = 5; } // 动态列表渲染部分-详情模块-图文-标签 message MdlDynDrawTag { // 标签类型 MdlDynDrawTagType type = 1; // 标签详情 MdlDynDrawTagItem item = 2; } // 动态列表部分-详情模块-图文-标签详情 message MdlDynDrawTagItem { // 跳转链接 string url = 1; // 标签内容 string text = 2; // 坐标-x int64 x = 3; // 坐标-y int64 y = 4; // 方向 int32 orientation = 5; // 来源 // 0:未知 1:淘宝 2:自营 int32 source = 6; // 商品id int64 item_id = 7; // 用户mid int64 mid = 8; // 话题id int64 tid = 9; // lbs信息 string poi = 10; // 商品标签链接 string schema_url = 11; } // 图文标签类型 enum MdlDynDrawTagType { mdl_draw_tag_none = 0; // 占位 mdl_draw_tag_common = 1; // 普通标签 mdl_draw_tag_goods = 2; // 商品标签 mdl_draw_tag_user = 3; // 用户昵称 mdl_draw_tag_topic = 4; // 话题名称 mdl_draw_tag_lbs = 5; // lbs标签 } // 动态列表渲染部分-详情模块-转发模块 message MdlDynForward { // 动态转发核心模块 套娃 DynamicItem item = 1; // 透传类型 // 0:分享 1:转发 int32 rtype = 2; } // 动态列表渲染部分-详情模块-直播 message MdlDynLive { // 房间号 int64 id = 1; // 跳转地址 string uri = 2; // 直播间标题 string title = 3; // 直播间封面 string cover = 4; // 标题1 例: 陪伴学习 string cover_label = 5; // 标题2 例: 54.6万人气 string cover_label2 = 6; // 直播状态 LiveState live_state = 7; // 直播角标 VideoBadge badge = 8; // 是否是预约召回 ReserveType reserve_type = 9; } // 动态列表渲染部分-详情模块-直播推荐 message MdlDynLiveRcmd { // 直播数据 string content = 1; // 是否是预约召回 ReserveType reserve_type = 2; } // 动态列表渲染部分-详情模块-播单 message MdlDynMedialist { // 播单id int64 id = 1; // 跳转地址 string uri = 2; // 主标题 string title = 3; // 副标题 string sub_title = 4; // 封面图 string cover = 5; // 封面类型 int32 cover_type = 6; // 角标 VideoBadge badge = 7; } // 动态列表渲染部分-详情模块-音频模块 message MdlDynMusic { // 音频id int64 id = 1; // 跳转地址 string uri = 2; // upId int64 up_id = 3; // 歌名 string title = 4; // 专辑封面 string cover = 5; // 展示项1 string label1 = 6; // upper string upper = 7; } // 动态-详情模块-pgc message MdlDynPGC { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // cid int64 cid = 7; // season_id int64 season_id = 8; // epid int64 epid = 9; // aid int64 aid = 10; // 视频源类型 MediaType media_type = 11; // 番剧类型 VideoSubType sub_type = 12; // 番剧是否为预览视频 bool is_preview = 13; // 尺寸信息 Dimension dimension = 14; // 角标,多个角标之前有间距 repeated VideoBadge badge = 15; // 是否能够自动播放 bool can_play = 16; // season PGCSeason season = 17; // 播放按钮 string play_icon = 18; // 时长 int64 duration = 19; // 跳转地址 string jump_url = 20; // 新角标,多个角标之前没有间距 repeated VideoBadge badge_category = 21; // 当前是否是pgc正片 bool is_feature = 22; } // 动态列表渲染部分-详情模块-订阅卡 message MdlDynSubscription { // 卡片物料id int64 id = 1; // 广告创意id int64 ad_id = 2; // 跳转地址 string uri = 3; // 标题 string title = 4; // 封面图 string cover = 5; // 广告标题 string ad_title = 6; // 角标 VideoBadge badge = 7; // 小提示 string tips = 8; } // 动态新附加卡 message MdlDynSubscriptionNew { //样式类型 MdlDynSubscriptionNewStyle style = 1; // 新订阅卡数据 oneof item { // MdlDynSubscription dyn_subscription = 2; // 直播推荐 MdlDynLiveRcmd dyn_live_rcmd = 3; } } // enum MdlDynSubscriptionNewStyle { mdl_dyn_subscription_new_style_nont = 0; // 占位 mdl_dyn_subscription_new_style_live = 1; // 直播 mdl_dyn_subscription_new_style_draw = 2; // 图文 } // 动态列表渲染部分-UGC合集 message MdlDynUGCSeason { // 标题 string title = 1; // 封面图 string cover = 2; // 秒开地址 string uri = 3; // 视频封面展示项 1 string cover_left_text_1 = 4; // 视频封面展示项 2 string cover_left_text_2 = 5; // 封面视频展示项 3 string cover_left_text_3 = 6; // 卡片物料id int64 id = 7; // inline播放地址 string inlineURL = 8; // 是否能够自动播放 bool can_play = 9; // 播放按钮 string play_icon = 10; // avid int64 avid = 11; // cid int64 cid = 12; // 尺寸信息 Dimension dimension = 13; // 时长 int64 duration = 14; // 跳转地址 string jump_url = 15; } // 播放器类型 enum MediaType { MediaTypeNone = 0; // 本地 MediaTypeUGC = 1; // UGC MediaTypePGC = 2; // PGC MediaTypeLive = 3; // 直播 MediaTypeVCS = 4; // 小视频 } // 查看更多-列表单条数据 message MixUpListItem { // 用户mid int64 uid = 1; // 特别关注 // 0:否 1:是 int32 special_attention = 2; // 小红点状态 // 0:没有 1:有 int32 reddot_state = 3; // 直播信息 MixUpListLiveItem live_info = 4; // 昵称 string name = 5; // 头像 string face = 6; // 认证信息 OfficialVerify official = 7; // 大会员信息 VipInfo vip = 8; // 关注状态 Relation relation = 9; } message MixUpListLiveItem { // 直播状态 // 0:未直播 1:直播中 bool status = 1; // 房间号 int64 room_id = 2; // 跳转地址 string uri = 3; } // 动态模块 message Module { // 类型 DynModuleType module_type = 1; oneof module_item { // 用户模块 1 ModuleAuthor module_author = 2; // 争议黄条模块 2 ModuleDispute module_dispute = 3; // 动态正文模块 3 ModuleDesc module_desc = 4; // 动态卡模块 4 ModuleDynamic module_dynamic = 5; // 点赞外露(废弃) ModuleLikeUser module_likeUser = 6; // 小卡模块 6 ModuleExtend module_extend = 7; // 大卡模块 5 ModuleAdditional module_additional = 8; // 计数模块 8 ModuleStat module_stat = 9; // 折叠模块 9 ModuleFold module_fold = 10; // 评论外露(废弃) ModuleComment module_comment = 11; // 外露交互模块(点赞、评论) 7 ModuleInteraction module_interaction = 12; // 转发卡-原卡用户模块 ModuleAuthorForward module_author_forward = 13; // 广告卡 ModuleAd module_ad = 14; // 通栏 ModuleBanner module_banner = 15; // 获取物料失败 ModuleItemNull module_item_null = 16; // 分享组件 ModuleShareInfo module_share_info = 17; // 相关推荐模块 ModuleRecommend module_recommend = 18; // 顶部模块 ModuleTop module_top = 19; // 底部模块 ModuleButtom module_buttom = 20; // 转发卡计数模块 ModuleStat module_stat_forward = 21; } } // 动态列表-用户模块-广告卡 message ModuleAd { // 广告透传信息 google.protobuf.Any source_content = 1; // 用户模块 ModuleAuthor module_author = 2; } // 动态-附加卡模块 message ModuleAdditional { // 类型 AdditionalType type = 1; oneof item { // 废弃 AdditionalPGC pgc = 2; // AdditionGoods goods = 3; // 废弃 AdditionVote vote = 4; // AdditionCommon common = 5; // AdditionEsport esport = 6; // 投票 AdditionVote2 vote2 = 8; // AdditionUgc ugc = 9; // up主预约发布卡 AdditionUP up = 10; } // 附加卡物料ID int64 rid = 7; } // 动态-发布人模块 message ModuleAuthor { // 用户mid int64 mid = 1; // 时间标签 string ptime_label_text = 2; // 用户详情 UserInfo author = 3; // 装扮卡片 DecorateCard decorate_card = 4; // 点击跳转链接 string uri = 5; // 右侧操作区域 - 三点样式 repeated ThreePointItem tp_list = 6; // 右侧操作区域样式枚举 ModuleAuthorBadgeType badge_type = 7; // 右侧操作区域 - 按钮样式 ModuleAuthorBadgeButton badge_button = 8; // 是否关注 // 1:关注 0:不关注 默认0,注:点赞列表使用,其他场景不使用该字段 int32 attend = 9; // 关注状态 Relation relation = 10; // 右侧操作区域 - 提权样式 Weight weight = 11; } // 动态列表渲染部分-用户模块-按钮 message ModuleAuthorBadgeButton { // 图标 string icon = 1; // 文案 string title = 2; // 状态 int32 state = 3; // 物料ID int64 id = 4; } // 右侧操作区域样式枚举 enum ModuleAuthorBadgeType { module_author_badge_type_none = 0; // 占位 module_author_badge_type_threePoint = 1; // 三点 module_author_badge_type_button = 2; // 按钮类型 module_author_badge_type_weight = 3; // 提权 } // 动态列表-用户模块-转发模板 message ModuleAuthorForward { // 展示标题 repeated ModuleAuthorForwardTitle title = 1; // 源卡片跳转链接 string url = 2; // 用户uid int64 uid = 3; // 时间标签 string ptime_label_text = 4; // 是否展示关注 bool show_follow = 5; // 源up主头像 string face_url = 6; // 双向关系 Relation relation = 7; // 右侧操作区域 - 三点样式 repeated ThreePointItem tp_list = 8; } // 动态列表-用户模块-转发模板-title部分 message ModuleAuthorForwardTitle { // 文案 string text = 1; // 跳转链接 string url = 2; } // 动态列表-通栏 message ModuleBanner { // 模块标题 string title = 1; // 卡片类型 ModuleBannerType type = 2; // 卡片 oneof item{ ModuleBannerUser user = 3; } // 不感兴趣文案 string dislike_text = 4; // 不感兴趣图标 string dislike_icon = 5; } // 动态列表-通栏类型 enum ModuleBannerType { module_banner_type_none = 0; // module_banner_type_user = 1; // } // 动态通栏-用户 message ModuleBannerUser { // 卡片列表 repeated ModuleBannerUserItem list = 1; } // 动态通栏-推荐用户卡 message ModuleBannerUserItem { // up主头像 string face = 1; // up主昵称 string name = 2; // up主uid int64 uid = 3; // 直播状态 LiveState live_state = 4; // 认证信息 OfficialVerify official = 5; // 大会员信息 VipInfo vip = 6; // 标签信息 string label = 7; // 按钮 AdditionalButton button = 8; // 跳转地址 string uri = 9; } // 底部模块 message ModuleButtom { // 计数模块 ModuleStat module_stat = 1; } // 评论外露模块 message ModuleComment { // 评论外露展示项 repeated CmtShowItem cmtShowItem = 1; } // 动态-描述文字模块 message ModuleDesc { // 描述信息(已按高亮拆分) repeated Description desc = 1; // 点击跳转链接 string jump_uri = 2; // 文本原本 string text = 3; } // 正文商品卡参数 message ModuleDescGoods { // 商品类型 // 1:淘宝 2:会员购 int32 source_type = 1; // 跳转链接 string jump_url = 2; // schema_url string schema_url = 3; // item_id int64 item_id = 4; // open_white_list repeated string open_white_list = 5; // use_web_v2 bool user_web_v2 = 6; // ad mark string ad_mark = 7; // schemaPackageName(Android用) string schema_package_name = 8; } // 动态-争议小黄条模块 message ModuleDispute { // 标题 string title = 1; // 描述内容 string desc = 2; // 跳转链接 string uri = 3; } // 动态-详情模块 message ModuleDynamic { // 类型 ModuleDynamicType type = 1; oneof module_item { //稿件 MdlDynArchive dyn_archive = 2; //pgc MdlDynPGC dyn_pgc = 3; //付费课程-系列 MdlDynCourSeason dyn_cour_season = 4; //付费课程-批次 MdlDynCourBatch dyn_cour_batch = 5; //转发卡 MdlDynForward dyn_forward = 6; //图文 MdlDynDraw dyn_draw = 7; //专栏 MdlDynArticle dyn_article = 8; //音频 MdlDynMusic dyn_music = 9; //通用卡方 MdlDynCommon dyn_common = 10; //直播卡 MdlDynLive dyn_common_live = 11; //播单 MdlDynMedialist dyn_medialist = 12; //小程序卡 MdlDynApplet dyn_applet = 13; //订阅卡 MdlDynSubscription dyn_subscription = 14; //直播推荐卡 MdlDynLiveRcmd dyn_live_rcmd = 15; //UGC合集 MdlDynUGCSeason dyn_ugc_season = 16; //订阅卡 MdlDynSubscriptionNew dyn_subscription_new = 17; } } // 动态详情模块类型 enum ModuleDynamicType { mdl_dyn_archive = 0; // 稿件 mdl_dyn_pgc = 1; // pgc mdl_dyn_cour_season = 2; // 付费课程-系列 mdl_dyn_cour_batch = 3; // 付费课程-批次 mdl_dyn_forward = 4; // 转发卡 mdl_dyn_draw = 5; // 图文 mdl_dyn_article = 6; // 专栏 mdl_dyn_music = 7; // 音频 mdl_dyn_common = 8; // 通用卡方 mdl_dyn_live = 9; // 直播卡 mdl_dyn_medialist = 10; // 播单 mdl_dyn_applet = 11; // 小程序卡 mdl_dyn_subscription = 12; // 订阅卡 mdl_dyn_live_rcmd = 13; // 直播推荐卡 mdl_dyn_ugc_season = 14; // UGC合集 mdl_dyn_subscription_new = 15; // 订阅卡 } // 动态-小卡模块 message ModuleExtend { // 详情 repeated ModuleExtendItem extend = 1; // 模块整体跳转uri string uri = 2; // 废弃 } // 动态-拓展小卡模块 message ModuleExtendItem { // 类型 DynExtendType type = 1; // 卡片详情 oneof extend { // 废弃 ExtInfoTopic ext_info_topic = 2; // 废弃 ExtInfoLBS ext_info_lbs = 3; // 废弃 ExtInfoHot ext_info_hot = 4; // 废弃 ExtInfoGame ext_info_game = 5; // ExtInfoCommon ext_info_common = 6; // ExtInfoOGV ext_info_ogv = 7; } } // 动态-折叠模块 message ModuleFold { // 折叠分类 FoldType fold_type = 1; // 折叠文案 string text = 2; // 被折叠的动态 string fold_ids = 3; // 被折叠的用户信息 repeated UserInfo fold_users = 4; } // 外露交互模块 message ModuleInteraction { // 外露交互模块 repeated InteractionItem interaction_item = 1; } // 获取物料失败模块 message ModuleItemNull { // 图标 string icon = 1; // 文案 string text = 2; } // 动态-点赞用户模块 message ModuleLikeUser { // 点赞用户 repeated LikeUser like_users = 1; // 文案 string display_text = 2; } // 相关推荐模块 message ModuleRecommend { // 模块标题 string module_title = 1; // 图片 string image = 2; // 标签 string tag = 3; // 标题 string title = 4; // 跳转链接 string jump_url = 5; // 序列化的广告信息 repeated google.protobuf.Any ad = 6; } // 分享模块 message ModuleShareInfo { // 展示标题 string title = 1; // 分享组件列表 repeated ShareChannel share_channels = 2; // share_origin string share_origin = 3; // 业务id string oid = 4; // sid string sid = 5; } // 动态-计数模块 message ModuleStat { // 转发数 int64 repost = 1; // 点赞数 int64 like = 2; // 评论数 int64 reply = 3; // 点赞拓展信息 LikeInfo like_info = 4; // 禁评 bool no_comment = 5; // 禁转 bool no_forward = 6; // 点击评论跳转链接 string reply_url = 7; // 禁评文案 string no_comment_text = 8; // 禁转文案 string no_forward_text = 9; } // 顶部模块 message ModuleTop { // 三点模块 repeated ThreePointItem tp_list = 1; } // 认证名牌 message Nameplate { // nid int64 nid = 1; // 名称 string name = 2; // 图片地址 string image = 3; // 小图地址 string image_small = 4; // 等级 string level = 5; // 获取条件 string condition = 6; } // 最新ep message NewEP { // 最新话epid int32 id = 1; // 更新至XX话 string index_show = 2; // 更新剧集的封面 string cover = 3; } // 空响应 message NoReply { } // 空请求 message NoReq { } // 认证信息 message OfficialVerify { // 127:未认证 0:个人 1:机构 int32 type = 1; // 认证描述 string desc = 2; // 是否关注 int32 is_atten = 3; } // PGC单季信息 message PGCSeason { // 是否完结 int32 is_finish = 1; // 标题 string title = 2; // 类型 int32 type = 3; } // message PlayerArgs { // int64 qn = 1; // int64 fnver = 2; // int64 fnval = 3; // int64 force_host = 4; } // 秒开通用参数 message PlayurlParam { // 清晰度 int32 qn = 1; // 流版本 int32 fnver = 2; // 流类型 int32 fnval = 3; // 是否强制使用域名 int32 force_host = 4; // 是否4k int32 fourk = 5; } // 推荐up主入参 message RcmdUPsParam { int64 dislike_ts = 1; } // 刷新方式 enum Refresh { refresh_new = 0; // 刷新列表 refresh_history = 1; // 请求历史 } // 关注关系 message Relation { // 关注状态 RelationStatus status = 1; // 关注 int32 is_follow = 2; // 被关注 int32 is_followed = 3; // 文案 string title = 4; } // 关注关系 枚举 enum RelationStatus { // 1-未关注 2-关注 3-被关注 4-互相关注 5-特别关注 relation_status_none = 0; relation_status_nofollow = 1; relation_status_follow = 2; relation_status_followed = 3; relation_status_mutual_concern = 4; relation_status_special = 5; } // enum ReserveType { reserve_none = 0; // 占位 reserve_recall = 1; // 预约召回 } // 分享渠道组件 message ShareChannel { // 分享名称 string name = 1; // 分享按钮图片 string image = 2; // 分享渠道 string channel = 3; } // 排序类型 message SortType { // 排序策略 // 1:推荐排序 2:最常访问 3:最近关注 int32 sort_type = 1; // 排序策略名称 string sort_type_name = 2; } // 三点-关注 message ThreePointAttention { // attention icon string attention_icon = 1; // 关注时显示的文案 string attention_text = 2; // not attention icon string not_attention_icon = 3; // 未关注时显示的文案 string not_attention_text = 4; // 当前关注状态 ThreePointAttentionStatus status = 5; } // 枚举-三点关注状态 enum ThreePointAttentionStatus { tp_not_attention = 0; // tp_attention = 1; // } // 三点-自动播放 旧版不维护 message ThreePointAutoPlay { // open icon string open_icon = 1; // 开启时显示文案 string open_text = 2; // close icon string close_icon = 3; // 关闭时显示文案 string close_text = 4; // 开启时显示文案v2 string open_text_v2 = 5; // 关闭时显示文案v2 string close_text_v2 = 6; // 仅wifi/免流 icon string only_icon = 7; // 仅wifi/免流 文案 string only_text = 8; // open icon v2 string open_icon_v2 = 9; // close icon v2 string close_icon_v2 = 10; } // 三点-默认结构(使用此背景、举报、删除) message ThreePointDefault { // icon string icon = 1; // 标题 string title = 2; // 跳转链接 string uri = 3; // id string id = 4; } // 三点-不感兴趣 message ThreePointDislike { // icon string icon = 1; // 标题 string title = 2; } // 三点-收藏 message ThreePointFavorite { // icon string icon = 1; // 标题 string title = 2; // 物料ID int64 id = 3; // 是否订阅 bool is_favourite = 4; // 取消收藏图标 string cancel_icon = 5; // 取消收藏文案 string cancel_title = 6; } // 三点Item message ThreePointItem { //类型 ThreePointType type = 1; oneof item { // 默认结构 ThreePointDefault default = 2; // 自动播放 ThreePointAutoPlay auto_player = 3; // 分享 ThreePointShare share = 4; // 关注 ThreePointAttention attention = 5; // 稍后在看 ThreePointWait wait = 6; // 不感兴趣 ThreePointDislike dislike = 7; // 收藏 ThreePointFavorite favorite = 8; } } // 三点-分享 message ThreePointShare { // icon string icon = 1; // 标题 string title = 2; // 分享渠道 repeated ThreePointShareChannel channel = 3; // 分享渠道名 string channel_name = 4; } // 三点-分享渠道 message ThreePointShareChannel { // icon string icon = 1; // 名称 string title = 2; } // 三点类型 enum ThreePointType { tp_none = 0; // 占位 background = 1; // 使用此背景 auto_play = 2; // 自动播放 share = 3; // 分享 wait = 4; // 稍后再播 attention = 5; // 关注 report = 6; // 举报 delete = 7; // 删除 dislike = 8; // 不感兴趣 favorite = 9; // 收藏 } // 三点-稍后在看 message ThreePointWait { // addition icon string addition_icon = 1; // 已添加时的文案 string addition_text = 2; // no addition icon string no_addition_icon = 3; // 未添加时的文案 string no_addition_text = 4; // avid int64 id = 5; } // enum ThumbType { cancel = 0; // thumb = 1; // } // 话题广场操作按钮 message TopicButton { // 按钮图标 string icon = 1; // 按钮文案 string title = 2; // 跳转 string jump_uri = 3; } // 综合页-话题广场 message TopicList { // 模块标题 string title = 1; // 话题列表 repeated TopicListItem topic_list_item = 2; // 发起活动 TopicButton act_button = 3; // 查看更多 TopicButton more_button = 4; } // 综合页-话题广场-话题 message TopicListItem { // 前置图标 string icon = 1; // 前置图标文案 string icon_title = 2; // 话题id int64 topic_id = 3; // 话题名 string topic_name = 4; // 跳转链接 string url = 5; // 卡片位次 int64 pos = 6; } // 综合页-无关注列表 message Unfollow { // 标题展示文案 string title = 1; // 无关注列表 repeated UnfollowUserItem list = 2; // trackID string TrackId = 3; } // 综合页-无关注列表 message UnfollowUserItem { // 是否有更新 bool has_update = 1; // up主头像 string face = 2; // up主昵称 string name = 3; // up主uid int64 uid = 4; // 排序字段 从1开始 int32 pos = 5; // 直播状态 LiveState live_state = 6; // 认证信息 OfficialVerify official = 7; // 大会员信息 VipInfo vip = 8; // up介绍 string sign = 9; // 标签信息 string label = 10; // 按钮 AdditionalButton button = 11; } // 动态顶部up列表-up主信息 message UpListItem { // 是否有更新 bool has_update = 1; // up主头像 string face = 2; // up主昵称 string name = 3; // up主uid int64 uid = 4; // 排序字段 从1开始 int64 pos = 5; // 用户类型 UserItemType user_item_type = 6; // 直播头像样式-日 UserItemStyle display_style_day = 7; // 直播头像样式-夜 UserItemStyle display_style_night = 8; // 直播埋点 int64 style_id = 9; // 直播状态 LiveState live_state = 10; // 分割线 bool separator = 11; // 跳转 string uri = 12; // UP主预约上报使用 bool is_recall = 13; } // 最常访问-查看更多 message UpListMoreLabel { // 文案 string title = 1; // 跳转地址 string uri = 2; } // 用户信息 message UserInfo { // 用户mid int64 mid = 1; // 用户昵称 string name = 2; // 用户头像 string face = 3; // 认证信息 OfficialVerify official = 4; // 大会员信息 VipInfo vip = 5; // 直播信息 LiveInfo live = 6; // 空间页跳转链接 string uri = 7; // 挂件信息 UserPendant pendant = 8; // 认证名牌 Nameplate nameplate = 9; } // 直播头像样式 message UserItemStyle { // string rect_text = 1; // string rect_text_color = 2; // string rect_icon = 3; // string rect_bg_color = 4; // string outer_animation = 5; } // 用户类型 enum UserItemType { user_item_type_none = 0; // user_item_type_live = 1; // user_item_type_live_custom = 2; // user_item_type_normal = 3; // user_item_type_extend = 4; // } // 头像挂件信息 message UserPendant { // pid int64 pid = 1; // 名称 string name = 2; // 图片链接 string image = 3; // 有效期 int64 expire = 4; } // 角标信息 message VideoBadge { // 文案 string text = 1; // 文案颜色-日间 string text_color = 2; // 文案颜色-夜间 string text_color_night = 3; // 背景颜色-日间 string bg_color = 4; // 背景颜色-夜间 string bg_color_night = 5; // 边框颜色-日间 string border_color = 6; // 边框颜色-夜间 string border_color_night = 7; // 样式 int32 bg_style = 8; // 背景透明度-日间 int32 bg_alpha = 9; // 背景透明度-夜间 int32 bg_alpha_night = 10; } // 番剧类型 enum VideoSubType { VideoSubTypeNone = 0; // 没有子类型 VideoSubTypeBangumi = 1; // 番剧 VideoSubTypeMovie = 2; // 电影 VideoSubTypeDocumentary = 3; // 纪录片 VideoSubTypeDomestic = 4; // 国创 VideoSubTypeTeleplay = 5; // 电视剧 } // 视频类型 enum VideoType { video_type_general = 0; //普通视频 video_type_dynamic = 1; //动态视频 video_type_playback = 2; //直播回放视频 } // 大会员信息 message VipInfo { // 大会员类型 int32 Type = 1; // 大会员状态 int32 status = 2; // 到期时间 int64 due_date = 3; // 标签 VipLabel label = 4; // 主题 int32 theme_type = 5; // 大会员角标 // 0:无角标 1:粉色大会员角标 2:绿色小会员角标 int32 avatar_subscript = 6; // 昵称色值,可能为空,色值示例:#FFFB9E60 string nickname_color = 7; } // 大会员标签 message VipLabel { // 图片地址 string path = 1; // 文本值 string text = 2; // 对应颜色类型 string label_theme = 3; } // 状态 enum VoteStatus { normal = 0; // 正常 anonymous = 1; // 匿名 } // 提权样式 message Weight { // 提权展示标题 string title = 1; // 下拉框内容 repeated WeightItem items = 2; // icon string icon = 3; } // 热门默认跳转按钮 message WeightButton { string jump_url = 1; // 展示文案 string title = 2; } // 提权不感兴趣 message WeightDislike { // 负反馈业务类型 作为客户端调用负反馈接口的参数 string feed_back_type = 1; // 展示文案 string title = 2; } // 提权样式 message WeightItem { // 类型 WeightType type = 1; oneof item { // 热门默认跳转按钮 WeightButton button = 2; // 提权不感兴趣 WeightDislike dislike = 3; } } // 枚举-提权类型 enum WeightType { weight_none = 0; // 默认 占位 weight_dislike = 1; // 不感兴趣 weight_jump = 2; // 跳链 } ================================================ FILE: bili-api/grpc/proto/bilibili/dynamic/interfaces/feed/v1/api.proto ================================================ syntax = "proto3"; package bilibili.main.dynamic.feed.v1; option java_multiple_files = true; option java_package = "bilibili.main.dynamic.feed.v1"; import "bilibili/dynamic/common/dynamic.proto"; // service Feed { // 发布页预校验 rpc CreateInitCheck(CreateInitCheckReq) returns (bilibili.dynamic.CreateCheckResp); // rpc SubmitCheck(SubmitCheckReq) returns (SubmitCheckRsp); // 创建动态 rpc CreateDyn(CreateDynReq) returns (bilibili.dynamic.CreateResp); // 根据name取uid rpc GetUidByName(bilibili.dynamic.GetUidByNameReq) returns (bilibili.dynamic.GetUidByNameRsp); // at用户推荐列表 rpc AtList(bilibili.dynamic.AtListReq) returns (bilibili.dynamic.AtListRsp); // at用户搜索列表 rpc AtSearch(bilibili.dynamic.AtSearchReq) returns (bilibili.dynamic.AtListRsp); // rpc ReserveButtonClick(ReserveButtonClickReq) returns (ReserveButtonClickResp); // rpc CreatePlusButtonClick(CreatePlusButtonClickReq) returns (CreatePlusButtonClickRsp); // rpc HotSearch(HotSearchReq) returns (HotSearchRsp); // rpc Suggest(SuggestReq) returns (SuggestRsp); // rpc DynamicButtonClick(DynamicButtonClickReq) returns (DynamicButtonClickRsp); // rpc CreatePermissionButtonClick(CreatePermissionButtonClickReq) returns (CreatePermissionButtonClickRsp); // rpc CreatePageInfos(CreatePageInfosReq) returns (CreatePageInfosRsp); } // 创建动态-请求 message CreateDynReq { // 用户创建接口meta信息 bilibili.dynamic.UserCreateMeta meta = 1; // 发布的内容 bilibili.dynamic.CreateContent content = 2; // 发布类型 bilibili.dynamic.CreateScene scene = 3; // 图片内容 repeated bilibili.dynamic.CreatePic pics = 4; // 转发源 bilibili.dynamic.DynIdentity repost_src = 5; // 动态视频 bilibili.dynamic.CreateDynVideo video = 6; // 通用模板类型:2048方图 2049竖图 其他值无效 int64 sketch_type = 7; // 通用模板的元内容(网页内容) bilibili.dynamic.Sketch sketch = 8; // 小程序的内容 bilibili.dynamic.Program program = 9; // 动态附加小卡 bilibili.dynamic.CreateTag dyn_tag = 10; // 动态附加大卡 bilibili.dynamic.CreateAttachCard attach_card = 11; // 特殊的创建选项 bilibili.dynamic.CreateOption option = 12; // bilibili.dynamic.CreateTopic topic = 13; // string upload_id = 14; } // message CreateInitCheckReq { // int32 scene = 1; // bilibili.dynamic.MetaDataCtrl meta = 2; // bilibili.dynamic.RepostInitCheck repost = 3; } // message CreatePageInfosReq { // int64 topic_id = 1; } // message CreatePageInfosRsp { // CreatePageTopicInfo topic = 1; } // message CreatePageTopicInfo { // int64 topic_id = 1; // string topic_name = 2; } // message CreatePermissionButtonClickReq { // DynamicButtonClickBizType type = 1; } // message CreatePermissionButtonClickRsp { } // message CreatePlusButtonClickReq { } // message CreatePlusButtonClickRsp { } // enum DynamicButtonClickBizType { DYNAMIC_BUTTON_CLICK_BIZ_TYPE_NONE = 0; // DYNAMIC_BUTTON_CLICK_BIZ_TYPE_LIVE = 1; // DYNAMIC_BUTTON_CLICK_BIZ_TYPE_DYN_UP = 2; // } // message DynamicButtonClickReq { } // message DynamicButtonClickRsp { } // message HotSearchReq { } // message HotSearchRsp { // message Item { // string words = 1; } // repeated Item items = 1; // string version = 2; } // message ReserveButtonClickReq { // int64 uid = 1; // int64 reserve_id = 2; // int64 reserve_total = 3; // int32 cur_btn_status = 4; // string spmid = 5; // int64 dyn_id = 6; // int64 dyn_type = 7; } // message ReserveButtonClickResp { // ReserveButtonStatus final_btn_status = 1; // ReserveButtonMode btn_mode = 2; // int64 reserve_update = 3; // string desc_update = 4; // bool has_activity = 5; // string activity_url = 6; // string toast = 7; } // enum ReserveButtonMode { RESERVE_BUTTON_MODE_NONE = 0; // RESERVE_BUTTON_MODE_RESERVE = 1; // RESERVE_BUTTON_MODE_UP_CANCEL = 2; // } // enum ReserveButtonStatus { RESERVE_BUTTON_STATUS_NONE = 0; // RESERVE_BUTTON_STATUS_UNCHECK = 1; // RESERVE_BUTTON_STATUS_CHECK = 2; // } // message SubmitCheckReq { // bilibili.dynamic.CreateContent content = 1; // repeated bilibili.dynamic.CreatePic pics = 2; } // message SubmitCheckRsp { } // message SuggestReq { // string s = 1; // int32 type = 2; } // message SuggestRsp { // repeated string list = 1; // string track_id = 2; // string version = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/gaia/gw/gw_api.proto ================================================ syntax = "proto3"; package bilibili.gaia.gw; option java_multiple_files = true; import "google/protobuf/empty.proto"; // 应用列表上报 service Gaia { // 应用列表上报 rpc ExUploadAppList(GaiaEncryptMsgReq) returns (UploadAppListReply); // 拉取rsa公钥 rpc ExFetchPublicKey(google.protobuf.Empty) returns (FetchPublicKeyReply); } // 待加密的pb对象 message DeviceAppList { // 上报类型 // first_installation:首次安装上报 first_open:每日启动上报 string source = 1; // 安装的系统程序列表 repeated string system_app_list = 2; //安装的用户程序列表 repeated string user_app_list = 3; } // 加密方式 enum EncryptType { INVALID_ENCRYPT_TYPE = 0; // 非法值 CLIENT_AES = 1; // 同客户端人工约定AES加密私钥,存储在客户端 SERVER_RSA_AES = 2; // 客户端随机生成一个用于AES加密的私钥,并用服务端下发的RSA公钥来加密 } // message FetchPublicKeyReply { // 版本号 string version = 1; // RSA公钥 string public_key = 2; // 公钥过期时间 int64 deadline = 3; } // message GaiaDeviceBasicInfo { //平台&应用信息 string platform = 1; //android/ios/web/h5; string device = 2; //运行设备, 用于区分不同的app, 见客户端传入的对应参数。对于苹果系统,device有效值为phone, pad;安卓无法区分phone和pad,留空即可。 string mobi_app = 3; //包类型,用于区分不同的app, 见客户端传入的对应参数(mobi_app );对于web端请求,请传空 string origin = 4; //客户端appkey, 用以区分不同的客户端,对应客户端请求参数中的appkey,如果无法获取可传空“” string app_id = 5; //app产品编号 //产品编号,由数据平台分配,粉=1,白=2,蓝=3,直播姬=4,HD=5,海外=6,OTT=7,漫画=8,TV野版=9,小视频=10,网易漫画=11,网易漫画lite=12,网易漫画HD=13,国际版=14 //应用的版本信息 string sdkver = 6; // SDK版本号 "sdkver": "2.6.6" string app_version = 7; // app版本 "app_version":"5.36.0" string app_version_code = 8; // app版本号 "app_version_code":"5360000" string build = 9; // app版本号,见客户端传入的对应参数;对于web端请求,请传空 //渠道信息 string channel = 10; //渠道标识,见客户端传入的对应参数;对于web端请求,请传空;对应chid //机器硬件信息 string brand = 11; //手机品牌,见客户端传入的对应参数; string model = 12; //手机型号,见客户端传入的对应参数 string osver = 13; //系统版本,见客户端传入的对应参数 string user_agent = 14; //设备标识信息 string buvid_local = 15; //本地设备唯一标识 string buvid = 16; //设备唯一标识 //登陆用户信息 string mid = 17; //最后一次登陆用户的mid,如果无登陆信息,传0即可 //本次启动信息 int64 fts = 18; // app首次启动时间 "fts":1530447775661 int32 first = 19; // 是否首次启动 是:0 否:1 //网络相关的信息 string network = 20; // 网络连接方式, WIFI/CELLULAR/OFFLINE/OTHERNET/ETHERNET "network":"WIFI", ESS_NETWORK_STATE、ACCESS_WIFI_STATE } // 应用列表上报-请求 message GaiaEncryptMsgReq { // 上报头部 GaiaMsgHeader header = 1; // 加密数据 bytes encrypt_payload = 2; } // 风控通用消息头 message GaiaMsgHeader { //加密类型 EncryptType encode_type = 1; //类型 PayloadType payload_type = 2; //RAS加密后的aes_key bytes encoded_aes_key = 3; //当前时间戳(ms) int64 ts = 4; } // 负载类型 enum PayloadType { INVALID_PAYLOAD = 0; //非法值 DEVICE_APP_LIST = 1; //设备app列表,对应DeviceAppList } // 应用列表上报-响应 message UploadAppListReply { // 上报响应id string trace_id = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/im/interfaces/inner-interface/v1/api.proto ================================================ syntax = "proto3"; package bilibili.im.interface.inner.interface.v1; option java_multiple_files = true; option java_package = "bilibili.im.interfaces.inner.interface1.v1"; // service InnerInterface { // rpc UpdateListInn(ReqOpBlacklist) returns(RspOpBlacklist); } // message BanUser { // 用户mid uint64 uid = 1; // 封禁业务 int32 limit = 2; // 封禁时间 int32 time = 3; // 模式 // 1:add 2:remove int32 mode = 4; } // message ReqOpBlacklist { // 需要封禁/解封的用户信息 repeated BanUser ban_users = 1; } // message RspOpBlacklist { // repeated uint64 failed_users = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/im/interfaces/v1/im.proto ================================================ syntax = "proto3"; package bilibili.im.interface.v1; option java_multiple_files = true; option java_package = "bilibili.im.interface1.v1"; import "bilibili/im/type/im.proto"; // 私信 service ImInterface { // 发送消息 rpc SendMsg (ReqSendMsg) returns (RspSendMsg); // 同步关系 rpc SyncRelation (ReqRelationSync) returns (RspRelationSync); // 确认同步进度 rpc SyncAck (ReqSyncAck) returns (RspSyncAck); // 同步版本拉取消息 rpc SyncFetchSessionMsgs (ReqSessionMsg) returns (RspSessionMsg); // 拉取会话记录列表 rpc GetSessions (ReqGetSessions) returns (RspSessions); // 拉取新消息 rpc NewSessions (ReqNewSessions) returns (RspSessions); // 拉取已读消息 rpc AckSessions (ReqAckSessions) returns (RspSessions); // 更新已读进度 rpc UpdateAck (ReqUpdateAck) returns (DummyRsp); // 置顶聊天 rpc SetTop (ReqSetTop) returns (DummyRsp); // 删除会话记录 rpc RemoveSession (ReqRemoveSession) returns (DummyRsp); // 未读私信数 rpc SingleUnread (ReqSingleUnread) returns (RspSingleUnread); // 我创建的应援团未读数 rpc MyGroupUnread (DummyReq) returns (RspMyGroupUnread); // 未关注的人批量设置为已读 rpc UpdateUnflwRead (DummyReq) returns (DummyRsp); // 应援团消息助手 rpc GroupAssisMsg (ReqGroupAssisMsg) returns (RspSessionMsg); // 更新应援团小助手消息已拉取进度 rpc AckAssisMsg (ReqAckAssisMsg) returns (DummyRsp); // 拉取会话详情 rpc SessionDetail (ReqSessionDetail) returns (bilibili.im.type.SessionInfo); // 批量拉取会话详情 rpc BatchSessDetail (ReqSessionDetails) returns (RspSessionDetails); // 批量删除会话 rpc BatchRmSessions (ReqBatRmSess) returns (DummyRsp); // 拉取最近私信分享列表 rpc ShareList (ReqShareList) returns (RspShareList); // rpc SpecificSingleUnread (ReqSpecificSingleUnread) returns (RspSpecificSingleUnread); // rpc GetSpecificSessions (ReqGetSpecificSessions) returns (RspSessions); // rpc GetLiveInfo(ReqLiveInfo) returns (RspLiveInfo); // rpc GetTotalUnread(ReqTotalUnread) returns (RspTotalUnread); // rpc ShowClearUnreadUI(ReqShowClearUnreadUI) returns (RspShowClearUnreadUI); // rpc CloseClearUnreadUI(ReqCloseClearUnreadUI) returns (RspCloseClearUnreadUI); // rpc UpdateTotalUnread(ReqUpdateTotalUnread) returns (RspUpdateTotalUnread); } // 空请求 message DummyReq { // uint32 idl = 1; } // 空响应 message DummyRsp { reserved 1; } // 表情资源信息 message EmotionInfo { // 表情 string text = 1; // 表情url string url = 2; // 表情大小 // 0:未知 1:min 2:max int32 size = 3; // gif url string gif_url = 4; } // enum ENUM_FOLD { FOLD_NO = 0; // FOLD_YES = 1; // FOLD_UNKNOWN = 2; // } // enum ENUM_UNREAD_TYPE{ UNREAD_TYPE_ALL = 0; // UNREAD_TYPE_FOLLOW = 1; // UNREAD_TYPE_UNFOLLOW = 2; // UNREAD_TYPE_DUSTBIN = 3; // } // message MsgDetail { // int64 msg_key = 1; // int64 seqno = 2; } // message MsgFeedUnreadRsp { // map unread = 1; } // 更新应援团小助手消息已拉取进度-请求 message ReqAckAssisMsg { // uint64 ack_seqno = 1; } // 拉取已读消息-请求 message ReqAckSessions { // uint64 begin_ts = 1; // uint32 end_ts = 2; // uint32 size = 3; } // 批量删除会话-请求 message ReqBatRmSess { } // message ReqCloseClearUnreadUI { } // message ReqGetMsg { // int64 talker_id = 1; // int32 session_type = 2; // repeated MsgDetail msg_detail = 3; } // 拉取会话记录列表-请求 message ReqGetSessions { // uint64 begin_ts = 1; // uint64 end_ts = 2; // uint32 size = 3; // uint32 session_type = 4; // uint32 unfollow_fold = 5; // uint32 group_fold = 6; // uint32 sort_rule = 7; // 青少年模式 uint32 teenager_mode = 8; // 课堂模式 uint32 lessons_mode = 9; } // -请求 message ReqGetSpecificSessions { // 具体会话详情 repeated SimpleSession talker_sessions = 1; } // 应援团消息助手-请求 message ReqGroupAssisMsg { // uint64 client_seqno = 1; // uint32 size = 2; } // message ReqLiveInfo { // int64 uid = 1; // int64 talker_id = 2; } // 拉取新消息-请求 message ReqNewSessions { // uint64 begin_ts = 1; // uint32 size = 2; // uint32 teenager_mode = 3; // 课堂模式 uint32 lessons_mode = 4; } // 同步关系-请求 message ReqRelationSync { // 客户端当前seqno uint64 client_relation_oplog_seqno = 1; } // 删除会话记录-请求 message ReqRemoveSession { // uint64 talker_id = 1; // uint32 session_type = 2; } // 发送消息-请求 message ReqSendMsg { // 消息内容 bilibili.im.type.Msg msg = 1; // string cookie = 2; // string cookie2 = 3; // int32 error_code = 4; // string dev_id = 5; } // 拉取会话详情-请求 message ReqSessionDetail { // uint64 talker_id = 1; // uint32 session_type = 2; // uint64 uid = 3; } // 批量拉取会话详情-请求 message ReqSessionDetails { // 会话详情请求列表 repeated ReqSessionDetail sess_ids = 1; } // 同步版本拉取消息-请求 message ReqSessionMsg { // uint64 talker_id = 1; // int32 session_type = 2; // uint64 end_seqno = 3; // uint64 begin_seqno = 4; // int32 size = 5; // int32 order = 6; // string dev_id = 7; } // 置顶聊天-请求 message ReqSetTop { // uint64 talker_id = 1; // uint32 session_type = 2; // // 0:置顶 1:取消置顶 uint32 op_type = 3; } // 拉取最近私信分享列表-请求 message ReqShareList { // 分页大小 最大20 int32 size = 1; } // message ReqShowClearUnreadUI { // int32 unread_type = 1; // int32 show_unfollow_list = 2; // int32 show_dustbin = 4; } // 未读私信数-请求 message ReqSingleUnread { // int32 unread_type = 1; // int32 show_unfollow_list = 2; // int64 uid = 3; // int32 show_dustbin = 4; } // -请求 message ReqSpecificSingleUnread { // 具体会话详情 repeated SimpleSession talker_sessions = 1; } // 确认同步进度-请求 message ReqSyncAck { // uint64 client_seqno = 1; } // message ReqTotalUnread { // int32 unread_type = 1; // int32 show_unfollow_list = 2; // int64 uid = 3; // int32 show_dustbin = 4; // int32 singleunread_on = 5; // int32 msgfeed_on = 6; // int32 sysup_on = 7; } // 更新已读进度-请求 message ReqUpdateAck { // 聊天对象uid,可以为用户id或者为群id uint64 talker_id = 1; // 会话类型 uint32 session_type = 2; // 已读的最大seqno uint64 ack_seqno = 3; } // message ReqUpdateIntercept { // int64 uid = 1; // int64 talker_id = 2; // int32 status = 3; } // message ReqUpdateTotalUnread { } // message RspCloseClearUnreadUI { } // message RspGetMsg { // repeated bilibili.im.type.Msg msg = 1; } // message RspLiveInfo { // int64 live_status = 1; // string jump_url = 2; } // 我创建的应援团未读数-响应 message RspMyGroupUnread { // 未读消息数 uint32 unread_count = 1; } // 同步关系-响应 message RspRelationSync { // int32 full = 1; // 增量日志 repeated bilibili.im.type.RelationLog relation_logs = 2; // 全量列表 repeated bilibili.im.type.FriendRelation friend_list = 3; // 服务器端最大的relation seqno uint64 server_relation_oplog_seqno = 4; // 全量列表 repeated bilibili.im.type.GroupRelation group_list = 5; } // 发送消息-响应 message RspSendMsg { // uint64 msg_key = 1; // 表情资源信息 repeated EmotionInfo e_infos = 2; // string msg_content = 3; // bilibili.im.type.KeyHitInfos key_hit_infos = 4; } // 批量拉取会话详情-响应 message RspSessionDetails { // 会话详情列表 repeated bilibili.im.type.SessionInfo sess_infos = 1; } // 同步版本拉取消息-响应 message RspSessionMsg { // repeated bilibili.im.type.Msg messages = 1; // int32 has_more = 2; // uint64 min_seqno = 3; // uint64 max_seqno = 4; // 表情资源信息 repeated EmotionInfo e_infos = 5; } // 拉取消息-响应 message RspSessions { // repeated bilibili.im.type.SessionInfo session_list = 1; // uint32 has_more = 2; // 标记反垃圾会话是否在清理中 bool anti_disturb_cleaning = 3; // 当session_list为空时,会返回该字段用于判断通讯录是否为空,1表示空,0表示非空 int32 is_address_list_empty = 4; // map system_msg = 5; // bool show_level = 6; } // 拉取最近私信分享列表-响应 message RspShareList { // 最近会话列表 repeated ShareSessionInfo session_list = 1; // int32 IsAddressListEmpty = 2; } // message RspShowClearUnreadUI { // bool display = 1; // string text = 2; } // 未读私信数-响应 message RspSingleUnread { // 未关注用户私信数 uint64 unfollow_unread = 1; // 已关注用户私信数 uint64 follow_unread = 2; // 未关注人列表是否有新业务通知 uint32 unfollow_push_msg = 3; // int32 dustbin_push_msg = 4; // int64 dustbin_unread = 5; // int64 biz_msg_unfollow_unread = 6; // int64 biz_msg_follow_unread = 7; } // -响应 message RspSpecificSingleUnread { // key -> 用户uid, value ->未读数 map talkerUnreadCnt = 1; // 总未读数 uint64 allUnreadCnt = 2; } // 确认同步进度-响应 message RspSyncAck { } // message RspTotalUnread { // SessionSingleUnreadRsp session_single_unread = 1; // MsgFeedUnreadRsp msg_feed_unread = 2; // SysMsgInterfaceLastMsgRsp sys_msg_interface_last_msg = 3; // int32 total_unread = 4; } // message RspUpdateTotalUnread { } // enum SESSION_TYPE { UNKNOWN = 0; // UN_FOLD_SESSION = 1; // UN_FOLLOW_SINGLE_SESSION = 2; // MY_GROUP_SESSION = 3; // ALL_SESSION = 4; // DUSTBIN_SESSION = 5; // } // message SessionSingleUnreadRsp { // int64 unfollow_unread = 1; // int64 follow_unread = 2; // int32 unfollow_push_msg = 3; // int32 dustbin_push_msg = 4; // int64 dustbin_unread = 5; } // 会话信息,用于私信分享 message ShareSessionInfo { // uint64 talker_id = 1; // string talker_uname = 2; // string talker_icon = 3; // 认证信息 // -1: 无认证 0:个人认证 1:机构认证 int32 official_type = 4; } // message SimpleSession { // 聊天对象uid,可以为用户id或者为群id uint64 talker_id = 1; // 会话类型 uint32 session_type = 2; } // message SysMsgInterfaceLastMsgRsp { // int32 unread = 1; // string title = 2; // string time = 3; // int64 id = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/im/type/im.proto ================================================ syntax = "proto3"; package bilibili.im.type; option java_multiple_files = true; // message AccountInfo { // string name = 1; // string pic_url = 2; } // enum CmdId { EN_CMD_ID_INVALID = 0; //非法cmd // msg_svr EN_CMD_ID_SEND_MSG = 200001; // 发消息 // sync_msg_svr EN_CMD_ID_SYNC_MSG = 500001; // 同步消息 EN_CMD_ID_SYNC_RELATION = 500002; // 同步相关链 EN_CMD_ID_SYNC_ACK = 500003; // 客户端同步消息完成后,向服务器确认同步进度 EN_CMD_ID_SYNC_FETCH_SESSION_MSGS = 500006; // 多端同步版本拉取消息 // session_svr EN_CMD_ID_SESSION_SVR_GET_SESSIONS = 1000001; // 拉会话列表 EN_CMD_ID_SESSION_SVR_NEW_SESSIONS = 1000002; // 新消息到达时获取会话列表 EN_CMD_ID_SESSION_SVR_ACK_SESSIONS = 1000003; // 获取已读位置有更新的会话列表 EN_CMD_ID_SESSION_SVR_UPDATE_ACK = 1000004; // 更新已读进度 EN_CMD_ID_SESSION_SVR_SET_TOP = 1000005; // 置顶/取消置顶 EN_CMD_ID_SESSION_SVR_REMOVE_SESSION = 1000007; // 删除会话 EN_CMD_ID_SESSION_SVR_SINGLE_UNREAD = 1000008; // 单聊未读信息数 EN_CMD_ID_SESSION_SVR_MY_GROUP_UNREAD = 1000009; // 我创建的应援团未读数 EN_CMD_ID_SESSION_SVR_UPDATE_UNFLW_READ = 1000010; // 未关注的人批量设置为已读 EN_CMD_ID_SESSION_SVR_GROUP_ASSIS_MSG = 1000011; // 应援团消息助手 EN_CMD_ID_SESSION_SVR_ACK_ASSIS_MSG = 1000012; // 更新应援团小助手消息已拉取进度 EN_CMD_ID_SESSION_SVR_SESSION_DETAIL = 1000015; // 拉会话详情 EN_CMD_ID_SESSION_SVR_BATCH_SESS_DETAIL = 1000016; // 批量拉会话详情 EN_CMD_ID_SESSION_SVR_BATCH_RM_SESSIONS = 1000017; // 批量删除会话 } // enum ENUM_BIZ_MSG_TYPE { BIZ_MSG_TYPE_NORMAL = 0; // BIZ_MSG_TYPE_CARD_VIDEO = 1; // } // message FriendRelation { // 用户mid uint64 uid = 1; // 用户昵称 string user_name = 2; // 头像url string face = 3; // vip类型 // 0:无 1:月度大会员 2:年度大会员 uint32 vip_level = 4; } // message GroupRelation { // uint64 group_id = 1; // uint64 owner_uid = 2; // uint32 group_type = 3; // uint32 group_level = 4; // string group_cover = 5; // string group_name = 6; // string group_notice = 7; // int32 status = 8; // int32 member_role = 9; // string fans_medal_name = 10; // uint64 room_id = 11; } // 关键词高亮文本 message HighText { // string title = 1; // string url = 2; // 表示高亮文本应该高亮第几个匹配的文本,ps:比如,“有疑问请联系客服,联系客服时,请说明具体的情况”,一共有2个匹配的文本,要高亮第一个匹配的,则index=1 uint32 index = 3; } // message ImgInfo { // string url = 1; // int32 width = 2; // int32 height = 3; // string imageType = 4; } // 关键词命中信息 message KeyHitInfos { // string toast = 1; // uint32 rule_id = 2; // repeated HighText high_text = 3; // } // message Medal { // int64 uid = 1; // int32 medal_id = 2; // int32 level = 3; // string medal_name = 4; // int32 score = 5; // int32 intimacy = 6; // int32 master_status = 7; // int32 is_receive = 8; // int64 medal_color_start = 9; // int64 medal_color_end = 10; // int64 medal_color_border = 11; // int64 medal_color_name = 12; // int64 medal_color_level = 13; // int64 guard_level = 14; } // message Msg { // 发送方mid uint64 sender_uid = 1; // 接收方类型 RecverType receiver_type = 2; // 接收方mid uint64 receiver_id = 3; // 客户端的序列id,用于服务端去重 uint64 cli_msg_id = 4; // 消息类型 MsgType msg_type = 5; // 消息内容 string content = 6; // 服务端的序列号x uint64 msg_seqno = 7; // 消息发送时间(服务端时间) uint64 timestamp = 8; // @用户列表 repeated uint64 at_uids = 9; // 多人消息 repeated uint64 recver_ids = 10; // 消息唯一标示 uint64 msg_key = 11; // 消息状态 uint32 msg_status = 12; // 是否为系统撤销 bool sys_cancel = 13; // 通知码 string notify_code = 14; // 消息来源 MsgSource msg_source = 15; // 如果msg.content有表情字符,则该参数需要置为1 int32 new_face_version = 16; // 命中关键词信息 KeyHitInfos key_hit_infos = 17; } // 消息来源 enum MsgSource { EN_MSG_SOURCE_UNKONW = 0; // EN_MSG_SOURCE_IOS = 1; // EN_MSG_SOURCE_ANDRIOD = 2; // EN_MSG_SOURCE_H5 = 3; // EN_MSG_SOURCE_PC = 4; // EN_MSG_SOURCE_BACKSTAGE = 5; // EN_MSG_SOURCE_BIZ = 6; // EN_MSG_SOURCE_WEB = 7; // EN_MSG_SOURCE_AUTOREPLY_BY_FOLLOWED = 8; // EN_MSG_SOURCE_AUTOREPLY_BY_RECEIVE_MSG = 9; // EN_MSG_SOURCE_AUTOREPLY_BY_KEYWORDS = 10; // EN_MSG_SOURCE_AUTOREPLY_BY_VOYAGE = 11; // EN_MSG_SOURCE_VC_ATTACH_MSG = 12; // }; // 消息类型 enum MsgType { // 基础消息类型 EN_INVALID_MSG_TYPE = 0; // 空空的~ EN_MSG_TYPE_TEXT = 1; // 文本消息 EN_MSG_TYPE_PIC = 2; // 图片消息 EN_MSG_TYPE_AUDIO = 3; // 语音消息 EN_MSG_TYPE_SHARE = 4; // 分享消息 EN_MSG_TYPE_DRAW_BACK = 5; // 撤回消息 EN_MSG_TYPE_CUSTOM_FACE = 6; // 自定义表情 EN_MSG_TYPE_SHARE_V2 = 7; // 分享v2消息 EN_MSG_TYPE_SYS_CANCEL = 8; // 系统撤销 EN_MSG_TYPE_MINI_PROGRAM = 9; // 小程序 // 扩展消息类型 EN_MSG_TYPE_NOTIFY_MSG = 10; // 业务通知 EN_MSG_TYPE_VIDEO_CARD = 11; // 视频卡片 EN_MSG_TYPE_ARTICLE_CARD = 12; // 专栏卡片 EN_MSG_TYPE_PICTURE_CARD = 13; // 图片卡 EN_MSG_TYPE_COMMON_SHARE_CARD = 14; // 异形卡 EN_MSG_TYPE_BIZ_MSG_TYPE = 50; // EN_MSG_TYPE_MODIFY_MSG_TYPE = 51; // // 功能类系统消息类型 EN_MSG_TYPE_GROUP_MEMBER_CHANGED = 101; // 群成员变更 EN_MSG_TYPE_GROUP_STATUS_CHANGED = 102; // 群状态变更 EN_MSG_TYPE_GROUP_DYNAMIC_CHANGED = 103; // 群动态变更 EN_MSG_TYPE_GROUP_LIST_CHANGED = 104; // 群列表变更 EM_MSG_TYPE_FRIEND_LIST_CHANGED = 105; // 好友列表变更 EN_MSG_TYPE_GROUP_DETAIL_CHANGED = 106; // 群详情发生变化 EN_MSG_TYPE_GROUP_MEMBER_ROLE_CHANGED = 107; // 群成员角色发生变化 EN_MSG_TYPE_NOTICE_WATCH_LIST = 108; // EN_MSG_TYPE_NOTIFY_NEW_REPLY_RECIEVED = 109; // 消息系统,收到新的reply EN_MSG_TYPE_NOTIFY_NEW_AT_RECIEVED = 110; // EN_MSG_TYPE_NOTIFY_NEW_PRAISE_RECIEVED = 111; // EN_MSG_TYPE_NOTIFY_NEW_UP_RECIEVED = 112; // EN_MSG_TYPE_NOTIFY_NEW_REPLY_RECIEVED_V2 = 113; // EN_MSG_TYPE_NOTIFY_NEW_AT_RECIEVED_V2 = 114; // EN_MSG_TYPE_NOTIFY_NEW_PRAISE_RECIEVED_V2 = 115; // EN_MSG_TYPE_GROUP_DETAIL_CHANGED_MULTI = 116; // 群详情发生变化,多端同步版本需要即时消息,无需落地 EN_MSG_TYPE_GROUP_MEMBER_ROLE_CHANGED_MULTI = 117; // 群成员角色发生变化,多端同步版本需要即时消息,无需落地 EN_MSG_TYPE_NOTIFY_ANTI_DISTURB = 118; // // 系统通知栏消息类型 EN_MSG_TYPE_SYS_GROUP_DISSOLVED = 201; // 群解散 EN_MSG_TYPE_SYS_GROUP_JOINED = 202; // 入群 EN_MSG_TYPE_SYS_GROUP_MEMBER_EXITED = 203; // 成员主动退群 EN_MSG_TYPE_SYS_GROUP_ADMIN_FIRED = 204; // 房管被撤 EN_MSG_TYPE_SYS_GROUP_MEMBER_KICKED = 205; // 成员被T EN_MSG_TYPE_SYS_GROUP_ADMIN_KICK_OFF = 206; // 管理T人 EN_MSG_TYPE_SYS_GROUP_ADMIN_DUTY = 207; // 管理上任 EN_MSG_TYPE_SYS_GROUP_AUTO_CREATED = 208; // 自动创建 EN_MSG_TYPE_SYS_FRIEND_APPLY = 210; // 好友申请 EN_MSG_TYPE_SYS_FRIEND_APPLY_ACK = 211; // 好友申请通过 EN_MSG_TYPE_SYS_GROUP_APPLY_FOR_JOINING = 212; // 用户加群申请 EN_MSG_TYPE_SYS_GROUP_ADMIN_ACCEPTED_USER_APPLY = 213; // 通知管理员,有其他管理员已经同意用户加群 // 聊天窗口通知消息类型 EN_MSG_TYPE_CHAT_MEMBER_JOINED = 301; // 入群 EN_MSG_TYPE_CHAT_MEMBER_EXITED = 302; // 退群 EN_MSG_TYPE_CHAT_GROUP_FREEZED = 303; // 冻结 EN_MSG_TYPE_CHAT_GROUP_DISSOLVED = 304; // 解散 EN_MSG_TYPE_CHAT_GROUP_CREATED = 305; // 开通应援团 EN_MSG_TYPE_CHAT_POPUP_SESSION = 306; // 弹出会话 } // 接收方类型 enum RecverType { EN_NO_MEANING = 0; // EN_RECVER_TYPE_PEER = 1; // 单人 EN_RECVER_TYPE_GROUP = 2; // 群 EN_RECVER_TYPE_PEERS = 3; // 多人 } // message RelationLog { // 操作类型 RelationLogType log_type = 1; // 操作seqno uint64 oplog_seqno = 2; // 好友信息 FriendRelation friend_relation = 3; // 群信息 GroupRelation group_relation = 4; } // enum RelationLogType { EN_INVALID_LOG_TYPE = 0; // EN_ADD_FRIEND = 1; // 添加好友 EN_REMOVE_FRIEND = 2; // 删除好友 EN_JOIN_GROUP = 3; // 加入群 EN_EXIT_GROUP = 4; // 退出群 } // enum SESSION_TYPE { INVALID_SESSION_TYPE = 0; // UN_FOLD_SESSION = 1; // UN_FOLLOW_SINGLE_SESSION = 2; // MY_GROUP_SESSION = 3; // ALL_SESSION = 4; // } // 会话详情 message SessionInfo { // uint64 talker_id = 1; // uint32 session_type = 2; // uint64 at_seqno = 3; // uint64 top_ts = 4; // string group_name = 5; // string group_cover = 6; // uint32 is_follow = 7; // uint32 is_dnd = 8; // uint64 ack_seqno = 9; // uint64 ack_ts = 10; // uint64 session_ts = 11; // uint32 unread_count = 12; // Msg last_msg = 13; // uint32 group_type = 14; // uint32 can_fold = 15; // uint32 status = 16; // uint64 max_seqno = 17; // 会话是否有业务通知 uint32 new_push_msg = 18; // 接收方是否设置接受推送 uint32 setting = 19; // uint32 is_guardian = 20; // int32 is_intercept = 21; // int32 is_trust = 22; // int32 system_msg_type = 23; // AccountInfo account_info = 24; // int32 live_status = 25; // int32 biz_msg_unread_count = 26; // UserLabel user_label = 27; } // message UserLabel { // int32 label_type = 1; // Medal medal = 2; // int32 guardian_relation = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/live/app/room/v1/room.proto ================================================ syntax = "proto3"; package bilibili.live.app.room.v1; option java_multiple_files = true; // message GetStudioListReq { // int64 room_id = 1; } // message GetStudioListResp { // message Pendants { // Pendant frame = 1; // Pendant badge = 2; } // message Pendant { // string name = 1; // int64 position = 2; // string value = 3; // string desc = 4; } // message StudioMaster { // int64 uid = 1; // int64 room_id = 2; // string uname = 3; // string face = 4; // Pendants pendants = 5; // string tag = 6; // int64 tag_type = 7; } // int64 status = 1; // repeated StudioMaster master_list = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/live/general/interfaces/v1/interfaces.proto ================================================ syntax = "proto3"; package bilibili.live.general.interfaces.v1; option java_multiple_files = true; option java_package = "bilibili.live.general.interfaces.v1"; // message GetOnlineRankReq { // int64 ruid = 1; // int64 room_id = 2; // int64 page = 3; // int64 page_size = 4; // string platform = 5; } // message GetOnlineRankResp { // message OnlineRankItem { // int64 uid = 1; // string uname = 2; // string face = 3; // int64 continue_watch = 4; // MedalInfo medal_info = 5; // int64 guard_level = 6; } // OnlineRankItem item = 1; // int64 online_num = 2; } // message MedalInfo { // int64 guard_level = 1; // int64 medal_color_start = 2; // int64 medal_color_end = 3; // int64 medal_color_border = 4; // string medal_name = 5; // int64 level = 6; // int64 target_id = 7; // int64 is_light = 8; } ================================================ FILE: bili-api/grpc/proto/bilibili/main/common/arch/doll/v1/doll.proto ================================================ syntax = "proto3"; package bilibili.main.common.arch.doll.v1; option java_multiple_files = true; // service Echo { // rpc Ping(PingRequest) returns(PingResponse); // rpc Say(SayRequest) returns(SayResponse); // rpc Error(ErrorRequest) returns(ErrorResponse); } // message ErrorRequest { // int32 error = 2; // int64 time = 1; // int64 delay = 3; } // message ErrorResponse { // string host = 1; // int64 time = 3; } // message PingRequest { // int64 time = 1; } // message PingResponse { // string host = 1; // int64 time = 3; } // message SayRequest { // string content = 1; } // message SayResponse { // string host = 1; // string content = 2; // int64 time = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/main/community/reply/v1/reply.proto ================================================ syntax = "proto3"; package bilibili.main.community.reply.v1; option java_multiple_files = true; import "bilibili/pagination/pagination.proto"; import "google/protobuf/any.proto"; // 评论区 service Reply { // 主评论列表接口 rpc MainList(MainListReq) returns (MainListReply); // 二级评论明细接口 rpc DetailList(DetailListReq) returns (DetailListReply); // 对话评论树接口 rpc DialogList(DialogListReq) returns (DialogListReply); // 评论预览接口 rpc PreviewList(PreviewListReq) returns (PreviewListReply); // 评论搜索item前置发布接口 rpc SearchItemPreHook(SearchItemPreHookReq) returns (SearchItemPreHookReply); // 评论搜索插入项目接口 rpc SearchItem(SearchItemReq) returns (SearchItemReply); // 评论at用户搜索接口 rpc AtSearch(AtSearchReq) returns (AtSearchReply); // 查询单条评论接口 rpc ReplyInfo(ReplyInfoReq) returns (ReplyInfoReply); // 用户回调上报接口 rpc UserCallback(UserCallbackReq) returns (UserCallbackReply); // 评论分享材料接口 rpc ShareRepliesInfo(ShareRepliesInfoReq) returns (ShareRepliesInfoResp); // 评论表情推荐列表接口 rpc SuggestEmotes(SuggestEmotesReq) returns (SuggestEmotesResp); } // 活动 message Activity { // 活动id int64 activity_id = 1; // 活动状态 // -1:待审 1:上线 int64 activity_state = 2; // 参与活动的输入框文案 string activity_placeholder = 3; } // 文章项目 message ArticleSearchItem { // 标题 string title = 1; // UP主昵称 string up_nickname = 2; // 封面 repeated string covers = 3; } // 评论at用户搜索组 message AtGroup { // 组类型 int32 group_type = 1; // 组标题 string group_name = 2; // 评论at用户搜索列表 repeated AtItem items = 3; } // 评论at用户搜索条目 message AtItem { // 用户mid int64 mid = 1; // 用户名 string name = 2; // 用户头像url string face = 3; // 用户是否关注 int32 fans = 4; // 用户认证类型 int32 official_verify_type = 5; } // 评论at用户搜索-响应 message AtSearchReply { // 评论at用户搜索组 repeated AtGroup groups = 1; } // 评论at用户搜索-请求 message AtSearchReq { // int64 mid = 1; // 关键字 string keyword = 2; } // 广告 message CM { // 广告数据(需要解包) google.protobuf.Any source_content = 1; } // 评论主体信息 message Content { // 评论文本 string message = 1; // 需要渲染的用户转义 map menber = 2; // 需要渲染的表情转义 map emote = 3; // 需要高亮的话题转义 map topic = 4; // 需要高亮的超链转义 map url = 5; // 投票信息 Vote vote = 6; // at到的用户mid列表 map at_name_to_mid = 7; // 富文本 RichText rich_text = 8; // 评论图片 repeated Picture pictures = 9; } // 图片信息 message Picture { // 图片URL string img_src = 1; // 图片宽度 double img_width = 2; // 图片高度 double img_height = 3; // 图片大小,单位KB double img_size = 4; } // 页面游标回复 message CursorReply { // 下页数据 int64 next = 1; // 上页数据 int64 prev = 2; // 是否到顶 bool isBegin = 3; // 是否到底 bool isEnd = 4; // 排序方式 // 2:时间 3:热度 Mode mode = 5; // 当前排序mode在切换按钮上的展示文案 string mode_text = 6; } // 页面游标请求 message CursorReq { // 下页数据 int64 next = 1; // 上页数据 int64 prev = 2; // 排序方式 Mode mode = 4; } // 二级评论明细-响应 message DetailListReply { // 页面游标 CursorReply cursor = 1; // 评论区显示控制字段 SubjectControl subject_control = 2; // 根评论信息(带二级评论) ReplyInfo root = 3; // 评论区的活动 Activity activity = 4; // LikeInfo likes = 5; // 排序方式 Mode mode = 6; // 当前排序mode在切换按钮上的展示文案 string mode_text = 7; // 分页 bilibili.pagination.FeedPaginationReply pagination_reply = 8; // string session_id = 9; } // 二级评论明细-请求 message DetailListReq { // 目标评论区id int64 oid = 1; // 目标评论区业务type int64 type = 2; // 根评论rpid int64 root = 3; // 目标评论rpid int64 rpid = 4; // 页面游标 CursorReq cursor = 5; // 来源标识 DetailListScene scene = 6; // 排序方式 Mode mode = 7; // 分页 bilibili.pagination.FeedPagination pagination = 8; } // 来源标识 enum DetailListScene { REPLY = 0; // 评论区展开 MSG_FEED = 1; // 回复消息推送 NOTIFY = 2; // } // 对话评论树-响应 message DialogListReply { // 页面游标 CursorReply cursor = 1; // 评论区显示控制字段 SubjectControl subject_control = 2; // 子评论列表 repeated ReplyInfo replies = 3; // 评论区的活动 Activity activity = 4; } // 对话评论树-请求 message DialogListReq { // 目标评论区id int64 oid = 1; // 目标评论区业务type int64 type = 2; // 根评论rpid int64 root = 3; // 对话评论rpid int64 rpid = 4; // 页面游标 CursorReq cursor = 5; } // 特效 message Effects { // string preloading = 1; } // 表情项 message Emote { // 表情大小 // 1:小 2:大 int64 size = 1; // 表情url string url = 2; // string jump_url = 3; // string jump_title = 4; // int64 id = 5; // int64 package_id = 6; // string gif_url = 7; // string text = 8; } // 商品项目 message GoodsSearchItem { // 商品id int64 id = 1; // 商品名 string name = 2; // 价钱 string price = 3; // 收入 string income = 4; // 图片 string img = 5; // 标签 string label = 6; } // message LikeInfo { // message Item { // Member member = 1; } // repeated Item items = 1; // string title = 2; } // 抽奖 message Lottery { // 抽奖id int64 lottery_id = 1; // 抽奖状态 // 0:未开奖 1:开奖中 2:已开奖 int64 lottery_status = 2; // 抽奖人mid int64 lottery_mid = 3; // 开奖时间 int64 lottery_time = 4; // int64 oid = 5; // int64 type = 6; // 发送时间 int64 ctime = 7; // 抽奖评论正文 Content content = 8; // 用户信息 Member member = 9; // 评论条目控制字段 ReplyControl reply_control = 10; } // 主评论列表-响应 message MainListReply { // 页面游标 CursorReply cursor = 1; // 评论列表 repeated ReplyInfo replies = 2; // 评论区显示控制字段 SubjectControl subject_control = 3; // UP置顶评论 ReplyInfo up_top = 4; // 管理员置顶评论 ReplyInfo admin_top = 5; // 投票置顶评论 ReplyInfo vote_top = 6; // 评论区提示 Notice notice = 7; // 抽奖评论 Lottery lottery = 8; // 活动 Activity activity = 9; // 精选评论区筛选后台信息 UpSelection up_selection = 10; // 广告 CM cm = 11; // 特效 Effects effects = 12; // Operation operation = 13; // repeated ReplyInfo top_replies = 14; // QoeInfo qoe = 15; // map callbacks = 16; // OperationV2 operation_v2 = 17; // 排序方式 Mode mode = 18; // 当前排序mode在切换按钮上的展示文案 string mode_text = 19; // 分页 bilibili.pagination.FeedPaginationReply pagination_reply = 20; // string session_id = 21; // string report_params = 22; // VoteCard vote_card = 23; } // 主评论列表-请求 message MainListReq { // 目标评论区id int64 oid = 1; // 目标评论区业务type int64 type = 2; // 页面游标 CursorReq cursor = 3; // 扩展数据json string extra = 4; // 广告扩展 string ad_extra = 5; // 目标评论rpid int64 rpid = 6; // int64 seek_rpid = 7; // 评论区筛选类型 取值可为: ["全部" "粉丝评论" "笔记长评"] string filter_tag_name = 8; // 排序方式 Mode mode = 9; // 分页 bilibili.pagination.FeedPagination pagination = 10; } // 用户信息 message Member { // 地区类型 enum RegionType { DEFAULT = 0; // 默认 MAINLAND = 1; // 大陆地区 GAT = 2; // } // enum ShowStatus { SHOWDEFAULT = 0; // 默认 ZOOMINMAINLAND = 1; // RAW = 2; // } // NFT地区 message Region { // 地区类型 RegionType type = 1; // 角标url string icon = 2; // ShowStatus show_status = 3; } // NFT信息 message NftInteraction { // string itype = 1; // string metadata_url = 2; // string nft_id = 3; // NFT地区 Region region = 4; } /**********基础信息**********/ // 用户mid int64 mid = 1; // 昵称 string name = 2; // 性别 string sex = 3; // 头像url string face = 4; // 等级 int64 level = 5; // 认证类型 int64 official_verify_type = 6; /**********VIP相关**********/ // 会员类型 // 0:不是大会员 1:月度会员 2:年度大会员 int64 vip_type = 7; // 会员状态 int64 vip_status = 8; // 会员样式 int64 vip_theme_type = 9; // 会员铭牌样式url string vip_label_path = 10; /**********装扮相关**********/ // 头像框url string garb_pendant_image = 11; // 装扮卡url string garb_card_image = 12; // 有关注按钮时的装扮卡url string garb_card_image_with_focus = 13; // 专属装扮页面url string garb_card_jump_url = 14; // 专属装扮id string garb_card_number = 15; // 专属装扮id显示颜色 string garb_card_fan_color = 16; // 是否为专属装扮卡 bool garb_card_is_fan = 17; /**********粉丝勋章相关**********/ // 粉丝勋章名 string fans_medal_name = 18; // 粉丝勋章等级 int64 fans_medal_level = 19; // 粉丝勋章显示颜色 int64 fans_medal_color = 20; // 会员昵称颜色 string vip_nickname_color = 21; // 会员角标 // 0:无角标 1:粉色大会员角标 2:绿色小会员角标 int32 vip_avatar_subscript = 22; // 会员标签文 string vip_label_text = 23; // 会员标颜色 string vip_label_theme = 24; // 粉丝勋章底色 int64 fans_medal_color_end = 25; // 粉丝勋章边框颜色 int64 fans_medal_color_border = 26; // 粉丝勋章名颜色 int64 fans_medal_color_name = 27; // 粉丝勋章等级颜色 int64 fans_medal_color_level = 28; // int64 fans_guard_level = 29; // int32 face_nft = 30; // 是否NFT头像 int32 face_nft_new = 31; // 是否为硬核会员 int32 is_senior_member = 32; // NFT信息 NftInteraction nft_interaction = 33; // string fans_guard_icon = 34; // string fans_honor_icon = 35; } // 用户信息V2 message MemberV2 { // 基本信息 message Basic { // 用户mid int64 mid = 1; // 昵称 string name = 2; // 性别 string sex = 3; // 头像url string face = 4; // 等级 int64 level = 5; } // 认证 message Official { // 认证类型 int64 verify_type = 1; } // 大会员 message Vip { // 会员类型 // 0:不是大会员 1:月度会员 2:年度大会员 int64 type = 1; // 会员状态 int64 status = 2; // 会员样式 int64 theme_type = 3; // 会员铭牌样式url string label_path = 4; // string nickname_color = 5; // int32 avatar_subscript = 6; // string label_text = 7; // string vip_label_theme = 8; } // 装扮 message Garb { // 头像框url string pendant_image = 1; // 装扮卡url string card_image = 2; // 有关注按钮时的装扮卡url string card_image_with_focus = 3; // 专属装扮页面url string card_jump_url = 4; // 专属装扮id string card_number = 5; // 专属装扮id显示颜色 string card_fan_color = 6; // 是否为专属装扮卡 bool card_is_fan = 7; } // 粉丝勋章 message Medal { // 粉丝勋章名 string name = 1; // 粉丝勋章等级 int64 level = 2; // 粉丝勋章显示颜色 int64 color_start = 3; // 粉丝勋章底色 int64 color_end = 4; // 粉丝勋章边框颜色 int64 color_border = 5; // 粉丝勋章名颜色 int64 color_name = 6; // 粉丝勋章等级颜色 int64 color_level = 7; // int64 guard_level = 8; // string first_icon = 9; // int64 level_bg_color = 11; } // 地区类型 enum RegionType { DEFAULT = 0; // 默认 MAINLAND = 1; // 大陆地区 GAT = 2; // } // enum ShowStatus { SHOWDEFAULT = 0; // ZOOMINMAINLAND = 1; // RAW = 2; // } // NFT地区 message Region { // 地区类型 RegionType type = 1; // 角标url string icon = 2; // ShowStatus show_status = 3; } // NFT信息 message Interaction { // string itype = 1; // string metadata_url = 2; // string nft_id = 3; // NFT地区 Region region = 4; } // NFT message Nft { // int32 face = 1; // Interaction interaction = 2; } // 硬核会员 message Senior { // 是否为硬核会员 int32 is_senior_member = 1; } // 契约 message Contractor { // 是否和up签订契约 bool is_contractor = 1; // 契约显示文案 string contract_desc = 2; } // 基本信息 Basic basic = 1; // 认证信息 Official official = 2; // 大会员信息 Vip vip = 3; // 装扮信息 Garb garb = 4; // 粉丝勋章信息 Medal medal = 5; // NFT信息 Nft nft = 6; // 硬核会员信息 Senior senior = 7; // 契约信息 Contractor contractor = 8; } // 排序方式 enum Mode { DEFAULT = 0; // UNSPECIFIED = 1; // 默认排序 MAIN_LIST_TIME = 2; // 按时间 MAIN_LIST_HOT = 3; // 按热度 } // message Notice { // int64 id = 1; // string content = 2; // string link = 3; } // message Operation { // int32 type = 1; // int64 id = 2; // OperationTitle title = 3; // OperationTitle subtitle = 4; // string link = 5; // string report_extra = 6; // string icon = 7; } // message OperationV2 { // int32 type = 1; // string prefix_text = 2; // OperationIcon icon = 3; // string title = 4; // string link = 5; // string report_extra = 6; } // message OperationIcon { // int32 position = 1; // string url = 2; } // message OperationTitle { // string content = 1; // bool is_highlight = 2; } // PGC视频项目 message PGCVideoSearchItem { // 标题 string title = 1; // 类别 string category = 2; // 封面 string cover = 3; } // 评论区预览-回复 message PreviewListReply { // 页面游标 CursorReply cursor = 1; // 评论列表 repeated ReplyInfo replies = 2; // 评论区显示控制字段 SubjectControl subject_control = 3; // UP置顶评论 ReplyInfo upTop = 4; // 管理员置顶评论 ReplyInfo admin_top = 5; // 投票置顶评论 ReplyInfo vote_top = 6; } // 评论区预览-请求 message PreviewListReq { // 目标评论区id int64 oid = 1; // 目标评论区业务type int64 type = 2; // 页面游标 CursorReq cursor = 3; } // message QoeInfo { // int64 id = 1; // int32 type = 2; // int32 style = 3; // string title = 4; // string feedback_title = 5; // repeated QoeScoreItem score_items = 6; // int64 display_rank = 7; } // message QoeScoreItem { // string title = 1; // string url = 2; // float score = 3; } // 评论条目标签信息 message ReplyCardLabel { // 标签文本 string text_content = 1; // 文本颜色 string text_color_day = 2; // 文本颜色夜间 string text_color_night = 3; // 标签颜色 string label_color_day = 4; // 标签颜色夜间 string label_color_night = 5; // string image = 6; // 标签类型 0:UP主觉得很赞 1:妙评 int32 type = 7; // 背景url string background = 8; // 背景宽 double background_width = 9; // 背景高 double background_height = 10; // 点击跳转url string jump_url = 11; // int64 effect = 12; // int64 effect_start_time = 13; } // 评论条目控制字段 message ReplyControl { // 操作行为标志 // 0:无 1:已点赞 2:已点踩 int64 action = 1; // 是否UP觉得很赞 bool up_like = 2; // 是否存在UP回复 bool up_reply = 3; // 是否显示关注按钮 bool show_follow_btn = 4; // 是否协管 bool is_assist = 5; // 是否展示标签 string label_text = 6; // 是否关注 bool following = 7; // 是否粉丝 bool followed = 8; // 是否被自己拉黑 bool blocked = 9; // 是否存在折叠的二级评论 bool has_folded_reply = 10; // 是否折叠 bool is_folded_reply = 11; // 是否UP置顶 bool is_up_top = 12; // 是否管理置顶 bool is_admin_top = 13; // 是否置顶投票评论 bool is_vote_top = 14; // 最大收起显示行数 int64 max_line = 15; // 该条评论可不可见 bool invisible = 16; // 是否和up签订契约 bool is_contractor = 17; // 是否是笔记评论 bool is_note = 18; // 评论条目标签列表 repeated ReplyCardLabel card_labels = 19; // 子评论数文案 "共x条回复" string sub_reply_entry_text = 20; // 子评论数文案 "相关回复共x条" string sub_reply_title_text = 21; // 契约显示文案 string contract_desc = 22; // 发布时间文案 "x天前发布" string time_desc = 23; // string biz_scene = 24; // IP属地信息 "IP属地:xxx" string location = 25; } // message ReplyExtra { // int64 season_id = 1; // int64 season_type = 2; // int64 ep_id = 3; // bool is_story = 4; } // 评论条目信息 message ReplyInfo { // 二级评论列表 repeated ReplyInfo replies = 1; // 评论rpid int64 id = 2; // 评论区对象id int64 oid = 3; // 评论区类型 int64 type = 4; // 发布者UID int64 mid = 5; // 根评论rpid int64 root = 6; // 父评论rpid int64 parent = 7; // 对话评论rpid int64 dialog = 8; // 点赞数 int64 like = 9; // 发布时间 int64 ctime = 10; // 回复数 int64 count = 11; // 评论主体信息 Content content = 12; // 发布者信息 Member member = 13; // 评论控制字段 ReplyControl reply_control = 14; // 发布者信息V2 MemberV2 member_v2 = 15; } // 查询单条评论-响应 message ReplyInfoReply { // 评论条目信息 ReplyInfo reply = 1; } // 查询单条评论-请求 message ReplyInfoReq { // 评论rpid int64 rpid = 1; // int32 scene = 2; } // 富文本 message RichText { // 富文本类型 oneof item { // 笔记 RichTextNote note = 1; } } // 笔记 message RichTextNote { // 预览文本 string summary = 1; // 笔记预览图片url列表 repeated string images = 2; // 笔记页面url string click_url = 3; // 发布日期 YYYY-mm-dd string last_mtime_text = 4; } // 评论搜索插入项目 message SearchItem { // string url = 1; // 项目 oneof item { // 商品 GoodsSearchItem goods = 2; // 视频 VideoSearchItem video = 3; // 专栏 ArticleSearchItem article = 4; } } // 评论搜索插入项目响应游标 message SearchItemCursorReply { // 是否有下一页 bool has_next = 1; // 下页 int64 next = 2; } // 评论搜索插入项目请求游标 message SearchItemCursorReq { // 下一页 int64 next = 1; // tab类型 SearchItemType item_type = 2; } // 评论搜索item前置发布-响应 message SearchItemPreHookReply { // 输入框的文案 string placeholder_text = 1; // 背景空白的时候的文案 string background_text = 2; // 有权限的tab栏的顺序 repeated SearchItemType ordered_type = 3; } // 评论搜索item前置发布-请求 message SearchItemPreHookReq { // 目标评论区id int64 oid = 1; // 目标评论区业务type int64 type = 2; } // 评论搜索插入项目-回复 message SearchItemReply { // SearchItemCursorReply cursor = 1; // 搜索的结果 repeated SearchItem items = 2; // 附加信息 SearchItemReplyExtraInfo extra = 3; } // message SearchItemReplyExtraInfo { // string event_id = 1; } // 评论搜索插入项目-请求 message SearchItemReq { // 页面游标 SearchItemCursorReq cursor = 1; // 目标评论区id int64 oid = 2; // 目标评论区业务type int64 type = 3; // 搜索关键词 string keyword = 4; } // enum SearchItemType { DEFAULT_ITEM_TYPE = 0; // GOODS = 1; // VIDEO = 2; // ARTICLE = 3; // } // enum SearchItemVideoSubType { UGC = 0; // PGC = 1; // } // 评论分享材料-请求 message ShareRepliesInfoReq { // 评论rpid列表 repeated int64 rpids = 1; // 目标评论区id int64 oid = 2; // 目标评论区业务type int64 type = 3; } // 评论分享材料-响应 message ShareRepliesInfoResp { // message ShareExtra { // bool is_pgc = 1; } // 评论分享条目列表 repeated ShareReplyInfo infos = 1; // 源内容标题 string from_title = 2; // 源内容UP主 string from_up = 3; // 源内容封面url string from_pic = 4; // 源内容页面url string url = 5; // logo url string slogan_pic = 6; // 标语 string slogan_text = 7; // ShareReplyTopic topic = 8; // ShareExtra extra = 9; } // 评论分享条目信息 message ShareReplyInfo { // 用户信息 Member member = 1; // 评论主体信息 Content content = 2; // 分享标题(评论发布者昵称) string title = 3; // 分享副标题 "发表了评论" string sub_title = 4; // 荣誉信息文案 "获得了up主点赞" string achievement_text = 5; // string label_url = 6; } // message ShareReplyTopic { // Topic topic = 1; // string origin_text = 2; } // 评论区控制字段 message SubjectControl { // 评论区筛选类型 message FilterTag { // 类型名 string name = 1; // string event_id = 2; } // UP主mid int64 up_mid = 1; // 自己是否为协管 bool is_assist = 2; // 是否只读 bool read_only = 3; // 是否有发起投票权限 bool has_vote_access = 4; // 是否有发起抽奖权限 bool has_lottery_access = 5; // 是否有被折叠评论 bool has_folded_reply = 6; // 空评论区背景文案 string bg_text = 7; // 是否被UP拉黑 bool up_blocked = 8; // 是否有发起活动权限 bool has_activity_access = 9; // 标题展示控制 bool show_title = 10; // 是否显示UP主操作标志 bool show_up_action = 11; // 是否显示评论区排序切换按钮 int64 switcher_type = 12; // 是否禁止输入框 bool input_disable = 13; // 根评论输入框背景文案 string root_text = 14; // 子评论输入框背景文案 string child_text = 15; // 评论总数 int64 count = 16; // 评论区标题 string title = 17; // 离开态输入框的文案 string giveup_text = 18; // 是否允许笔记 bool has_note_access = 19; // bool disable_jump_emote = 20; // string empty_background_text_plain = 21; // string empty_background_text_highlight = 22; // string empty_background_uri = 23; // 评论区筛选类型列表 repeated FilterTag support_filter_tags = 24; } // 评论表情推荐列表-请求 message SuggestEmotesReq { // 目标评论区id int64 oid = 1; // 目标评论区业务type int64 type = 2; } // 评论表情推荐列表-响应 message SuggestEmotesResp { // 表情推荐列表 repeated Emote emotes = 1; } // 话题项 message Topic { // 跳转url string link = 1; // 话题id int64 id = 2; } // UGC视频项目 message UGCVideoSearchItem { // 标题 string title = 1; // UP主昵称 string up_nickname = 2; // 时长(单位为秒) int64 duration = 3; // 封面 string cover = 4; } // 精选评论 message UpSelection { // 待审评论数 int64 pending_count = 1; // 忽略评论数 int64 ignore_count = 2; } // 超链项 message Url { // 扩展字段 message Extra { // int64 goods_item_id = 1; // string goods_prefetched_cache = 2; // int32 goods_show_type = 3; // 热词搜索 bool is_word_search = 4; // int64 goods_cm_control = 5; } // 标题 string title = 1; // int64 state = 2; // 图标url string prefix_icon = 3; // 客户端内跳转uri string app_url_schema = 4; // string app_name = 5; // string app_package_name = 6; // 点击上报数据 string click_report = 7; // 是否半屏打开 bool is_half_screen = 8; // 展现上报数据 string exposure_report = 9; // 扩展字段 Extra extra = 10; // 是否下划线 bool underline = 11; // bool match_once = 12; // 网页url string pc_url = 13; // int32 icon_position = 14; } // enum UserCallbackAction { Dismiss = 0; // } // 用户回调上报-响应 message UserCallbackReply {} // 用户回调上报-请求 message UserCallbackReq { // 用户mid int64 mid = 1; // UserCallbackScene scene = 2; // UserCallbackAction action = 3; // 目标评论区id int64 oid = 4; // 目标评论区业务type int64 type = 5; } // enum UserCallbackScene { Insert = 0; // } // 视频项目 message VideoSearchItem { // SearchItemVideoSubType type = 1; // oneof video_item { // UGC视频 UGCVideoSearchItem ugc = 2; // PGC视频 PGCVideoSearchItem pgc = 3; } } // 投票信息 message Vote { // 投票id int64 id = 1; // 投票标题 string title = 2; // 参与人数 int64 count = 3; } // message VoteCard{ // 投票id int64 vote_id = 1; // 投票标题 string title = 2; // int64 count = 3; // repeated VoteCardOption options = 4; // int64 my_vote_option = 5; } // message VoteCardOption{ // int64 idx = 1; // string desc = 2; // int64 count = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/metadata/device/device.proto ================================================ syntax = "proto3"; package bilibili.metadata.device; option java_multiple_files = true; // 设备信息 // gRPC头部:x-bili-device-bin message Device { // 产品id // 粉 白 蓝 直播姬 HD 海外 OTT 漫画 TV野版 小视频 网易漫画 网易漫画 网易漫画HD 国际版 东南亚版 // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 30 int32 app_id = 1; // 构建id int32 build = 2; // 设备buvid string buvid = 3; // 包类型 string mobi_app = 4; // 平台类型 // ios android string platform = 5; // 设备类型 string device = 6; // 渠道 string channel = 7; // 手机品牌 string brand = 8; // 手机型号 string model = 9; // 系统版本 string osver = 10; // 本地设备指纹 string fp_local = 11; // 远程设备指纹 string fp_remote = 12; // APP版本号 string version_name = 13; // 设备指纹, 不区分本地或远程设备指纹,作为推送目标的索引 string fp = 14; // 首次启动时的毫秒时间戳 int64 fts = 15; } ================================================ FILE: bili-api/grpc/proto/bilibili/metadata/fawkes/fawkes.proto ================================================ syntax = "proto3"; package bilibili.metadata.fawkes; option java_multiple_files = true; // message FawkesReply { // 客户端在fawkes系统中对应的已发布最新的config版本号 string config = 1; // 客户端在fawkes系统中对应的已发布最新的ff版本号 string ff = 2; } // message FawkesReq { // 客户端在fawkes系统的唯一名, 如 `android64` string appkey = 1; // 客户端在fawkes系统中的环境参数, 如 `prod` string env = 2; // 启动id, 8 位 0~9, a~z 组成的字符串 string session_id = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/metadata/locale/locale.proto ================================================ syntax = "proto3"; package bilibili.metadata.locale; option java_multiple_files = true; // 区域标识 // gRPC头部:x-bili-locale-bin message Locale { // App设置的locale LocaleIds c_locale = 1; // 系统默认的locale LocaleIds s_locale = 2; // sim卡的国家码+运营商码 string sim_code = 3; // 时区 string timezone = 4; } // Defined by https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html message LocaleIds { // A language designator is a code that represents a language. string language = 1; // Writing systems. string script = 2; // A region designator is a code that represents a country or an area. string region = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/metadata/metadata.proto ================================================ syntax = "proto3"; package bilibili.metadata; option java_multiple_files = true; // 请求元数据 // gRPC头部:x-bili-metadata-bin message Metadata { // 登录 access_key string access_key = 1; // 包类型, 如 `android` string mobi_app = 2; // 运行设备, 留空即可 string device = 3; // 构建id, 如 `7380300` int32 build = 4; // APP分发渠道, 如 `master` string channel = 5; // 设备唯一标识 string buvid = 6; // 平台类型, 如 `android` string platform = 7; } ================================================ FILE: bili-api/grpc/proto/bilibili/metadata/network/network.proto ================================================ syntax = "proto3"; package bilibili.metadata.network; option java_multiple_files = true; // 网络类型标识 // gRPC头部:x-bili-network-bin message Network { // 网络类型 NetworkType type = 1; // 免流类型 TFType tf = 2; // 运营商 string oid = 3; } // 网络类型 enum NetworkType { NT_UNKNOWN = 0; // 未知 WIFI = 1; // WIFI CELLULAR = 2; // 蜂窝网络 OFFLINE = 3; // 未连接 OTHERNET = 4; // 其他网络 ETHERNET = 5; // 以太网 } // 免流类型 enum TFType { TF_UNKNOWN = 0; // 正常计费 U_CARD = 1; // 联通卡 U_PKG = 2; // 联通包 C_CARD = 3; // 移动卡 C_PKG = 4; // 移动包 T_CARD = 5; // 电信卡 T_PKG = 6; // 电信包 } ================================================ FILE: bili-api/grpc/proto/bilibili/metadata/parabox/parabox.proto ================================================ syntax = "proto3"; package bilibili.metadata.parabox; option java_multiple_files = true; // message Exp { // int64 id = 1; // int32 bucket = 2; } // message Exps { // repeated Exp exps = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/metadata/restriction/restriction.proto ================================================ syntax = "proto3"; package bilibili.metadata.restriction; option java_multiple_files = true; // 模式类型 enum ModeType { NORMAL = 0; // 正常模式 TEENAGERS = 1; // 青少年模式 LESSONS = 2; // 课堂模式 } // 限制条件 message Restriction { // 青少年模式开关状态 bool teenagers_mode = 1; // 课堂模式开关状态 bool lessons_mode = 2; // 模式类型(旧版) ModeType mode = 3; // app 审核review状态 bool review = 4; // 客户端是否选择关闭个性化推荐 bool disable_rcmd = 5; } ================================================ FILE: bili-api/grpc/proto/bilibili/pagination/pagination.proto ================================================ syntax = "proto3"; package bilibili.pagination; option java_multiple_files = true; // 分页信息 message FeedPagination { // int32 page_size = 1; // string offset = 2; // bool is_refresh = 3; } // 分页信息 message FeedPaginationReply { // string next_offset = 1; // string prev_offset = 2; // string last_read_offset = 3; } // 分页信息 message Pagination { // int32 page_size = 1; // string next = 2; } // 分页信息 message PaginationReply { // string next = 1; // string prev = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/pangu/gallery/v1/gallery.proto ================================================ syntax = "proto3"; package bilibili.pangu.gallery.v1; option java_multiple_files = true; import "google/protobuf/empty.proto"; // service GalleryInterface { // rpc Ping (google.protobuf.Empty) returns (google.protobuf.Empty); // rpc UserInfo (GetUserInfoReq) returns (GetUserInfoReply); // rpc ListNFTByMid (ListNFTByMidReq) returns (ListNFTByMidReply); // rpc ListOrderByMid (ListOrderByMidReq) returns (ListOrderByMidReply); // rpc BasicInfo (BasicInfoReq) returns (BasicInfoReply); // rpc UserCheck (UserCheckReq) returns (UserCheckReply); // rpc AgreePolicy (AgreePolicyReq) returns (AgreePolicyReply); // rpc GetLastPolicy (GetLastPolicyReq) returns (GetLastPolicyReply); } // message AgreePolicyReply { } // message AgreePolicyReq { // PolicyType policy_type = 1; // string version = 2; } // message BasicInfoReply { // string customer_service_url = 1; // string agreement_url = 2; // string privacy_url = 3; // repeated Link links = 4; } // message BasicInfoReq { // int64 mid = 1; } // message Display { // string bg_theme_light = 1; // string bg_theme_night = 2; // string nft_poster = 3; // string nft_raw = 4; } // enum GT14Status { LT14 = 0; // GE14 = 1; // UNKNOWN_GT14 = 2; // } // message GetLastPolicyReply { // string short_desc = 1; // string detail_jump = 2; // string version = 3; } // message GetLastPolicyReq { // PolicyType policy_type = 1; } // message GetUserInfoReply { // int64 mid = 1; // string name = 2; // string address = 3; // string avatar_url = 4; // string help_url = 5; } // message GetUserInfoReq { // int64 mid = 1; } // message Link { // string name = 1; // string link_url = 2; // string track_event_id = 3; } // message ListNFTByMidReply { // repeated NFT nfts = 1; // int64 anchor_id = 2; // bool end = 3; } // message ListNFTByMidReq { // int64 mid = 1; // string category = 2; // string biz_type = 3; // int64 anchor_id = 4; // int64 page_size = 5; } // message ListOrderByMidReply { // repeated Order orders = 1; // int64 anchor_id = 2; // bool end = 3; } // message ListOrderByMidReq { // int64 mid = 1; // int64 anchor_id = 2; // int64 page_size = 3; } // message NFT { // string nft_id = 1; // string item_name = 2; // string serial_number = 3; // string issuer = 4; // Display display = 5; // string detail_url = 6; // NFTStatus nft_status = 7; // int64 item_id = 8; } // enum NFTStatus { UNDEFINED = 0; // NORMAL = 1; // DOING = 2; // } // message Order { // string item_name = 1; // string serial_number = 2; // string tx_hash = 3; // string tx_time = 4; // string issuer = 5; // string issue_time = 6; // string token_id = 7; // Display display = 8; // string contract_address = 9; // string hash_jump = 10; // string contract_jump = 11; // bool disable_browser_jump = 12; } // enum PolicyAgreeStatus { UNSIGNED = 0; // ACCEPTED = 1; // EXPIRED = 2; // } // enum PolicyType { UNKNOWN_POLICY = 0; // WALLET = 1; // SALE = 2; // } // message UserCheckReply { // int32 policy_status = 1; // int32 gt14 = 2; } // message UserCheckReq { // int64 mid = 1; // int32 policy_type = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/pgc/gateway/player/v1/playurl.proto ================================================ syntax = "proto3"; package bilibili.pgc.gateway.player.v1; option java_multiple_files = true; import "bilibili/app/playurl/v1/playurl.proto"; // 播放地址 service PlayURL { // 播放页信息 rpc PlayView (PlayViewReq) returns (PlayViewReply); // 获取投屏地址 rpc Project (ProjectReq) returns (ProjectReply); // 直播播放页信息 rpc LivePlayView (LivePlayViewReq) returns (LivePlayViewReply); } // 其他业务信息 message BusinessInfo { // 当前视频是否是预览 bool is_preview = 1; // 用户是否承包过 bool bp = 2; // drm使用 string marlin_token = 3; } // 事件 message Event { // 震动 Shake shake = 1; } // 播放信息 message LivePlayInfo { // int32 current_qn = 1; // repeated QualityDescription quality_description = 2; // repeated ResponseDataUrl durl = 3; } // 直播播放页信息-响应 message LivePlayViewReply { // 房间信息 RoomInfo room_info = 1; // 播放信息 LivePlayInfo play_info = 2; } // 直播播放页信息-请求 message LivePlayViewReq { // 剧集epid int64 ep_id = 1; // 清晰度 // 0,10000:原画 400:蓝光 250:超清 150:高清 80:流畅 uint32 quality = 2; // 类型 // 0:音频 2:hevc 4:dash 8:p2p, 16:蒙版 uint32 ptype = 3; // 是否请求https bool https = 4; // 0:默认直播间播放 1:投屏播放 uint32 play_type = 5; // 投屏设备 // 0:默认其他 1:OTT设备 int32 device_type = 6; } // 禁用功能配置 message PlayAbilityConf { bool background_play_disable = 1; // 后台播放 bool flip_disable = 2; // 镜像反转 bool cast_disable = 3; // 投屏 bool feedback_disable = 4; // 反馈 bool subtitle_disable = 5; // 字幕 bool playback_rate_disable = 6; // 播放速度 bool time_up_disable = 7; // 定时停止 bool playback_mode_disable = 8; // 播放方式 bool scale_mode_disable = 9; // 画面尺寸 bool like_disable = 10; // 赞 bool dislike_disable = 11; // 踩 bool coin_disable = 12; // 投币 bool elec_disable = 13; // 充电 bool share_disable = 14; // 分享 bool screen_shot_disable = 15; // 截图 bool lock_screen_disable = 16; // 锁定 bool recommend_disable = 17; // 相关推荐 bool playback_speed_disable = 18; // 播放速度 bool definition_disable = 19; // 清晰度 bool selections_disable = 20; // 选集 bool next_disable = 21; // 下一集 bool edit_dm_disable = 22; // 编辑弹幕 bool small_window_disable = 23; // 小窗 bool shake_disable = 24; // 震动 bool outer_dm_disable = 25; // 外层面板弹幕设置 bool inner_dm_disable = 26; // 三点内弹幕设置 bool freya_enter_disable = 27; // 一起看入口 bool dolby_disable = 28; // 杜比音效 bool freya_full_disable = 29; // 全屏一起看入口 bool skip_oped_switch_disable = 30; // } // 播放页信息-响应 message PlayViewReply { // 视频流信息 bilibili.app.playurl.v1.VideoInfo video_info = 1; // 播放控件用户自定义配置 PlayAbilityConf play_conf = 2; // 业务需要的其他信息 BusinessInfo business = 3; // 事件 Event event = 4; } // 播放页信息-请求 message PlayViewReq { // 剧集epid int64 epid = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string from_spmid = 10; // 青少年模式 int32 teenagers_mode = 11; // 视频编码 bilibili.app.playurl.v1.CodeType prefer_codec_type = 12; // 是否强制请求预览视频 bool is_preview = 13; // 一起看房间id int64 room_id = 14; } // 投屏地址-响应 message ProjectReply { bilibili.app.playurl.v1.PlayURLReply project = 1; } // 投屏地址-请求 message ProjectReq { // 剧集epid int64 ep_id = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 forceHost = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string fromSpmid = 10; // 使用协议 // 0:默认乐播 1:自建协议 2:云投屏 3:airplay int32 protocol = 11; // 投屏设备 // 0:默认其他 1:OTT设备 int32 device_type = 12; // bool use_new_project_code = 13; } // message QualityDescription { // int32 qn = 1; // string desc = 2; } // message ResponseDataUrl { string url = 1; // 表示stream类型,按位表示 // Value| 1 | 1 | 1 | 1 | 1 // -------------------------------------------- // desc | mask | p2p | dash | hevc | only-audio uint32 stream_type = 2; // 表示支持p2p的cdn厂商,按位表示 // 值 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 // ----------------------------------------------- // CDN | hw | bdy | bsy | ws | txy | qn | js | bvc uint32 ptag = 3; } // 房间信息 message RoomInfo { // 房间长号 int64 room_id = 1; // 主播mid int64 uid = 2; // 状态相关 RoomStatusInfo status = 3; // 展示相关 RoomShowInfo show = 4; } // 房间信息-展示相关 message RoomShowInfo { // 短号 int64 short_id = 1; // 人气值 int64 popularity_count = 8; // 最近一次开播时间戳 int64 live_start_time = 10; } // 房间信息-状态相关 message RoomStatusInfo { // 直播间状态 // 0:未开播 1:直播中 2:轮播中 int64 live_status = 1; // 横竖屏方向 // 0:横屏 1:竖屏 int64 live_screen_type = 2; // 是否开播过标识 int64 live_mark = 3; // 封禁状态 // 0:未封禁 1:审核封禁 2:全网封禁 int64 lock_status = 4; // 封禁时间戳 int64 lock_time = 5; // 隐藏状态 // 0:不隐藏 1:隐藏 int64 hidden_status = 6; // 隐藏时间戳 int64 hidden_time = 7; // 直播类型 // 0:默认 1:摄像头直播 2;录屏直播 3:语音直播 int64 live_type = 8; // int64 room_shield = 9; } // 震动 message Shake { // 文件地址 string file = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/pgc/gateway/player/v2/playurl.proto ================================================ syntax = "proto3"; package bilibili.pgc.gateway.player.v2; option java_multiple_files = true; import "google/protobuf/timestamp.proto"; // 视频url service PlayURL { // 播放页信息 rpc PlayView (PlayViewReq) returns (PlayViewReply); // rpc PlayViewComic(PlayViewReq) returns (PlayViewReply); } // message Animation { // map qn_svga_animation_map = 1; } // message AudioMaterialProto { // string audio_id = 1; // string title = 2; // string edition = 3; // uint64 person_id = 4; // string person_name = 5; // string person_avatar = 6; // repeated DashItem audio = 7; } // 角标信息 message BadgeInfo { // 角标文案 string text = 1; // 角标色值 string bg_color = 2; // 角标色值-夜间模式 string bg_color_night = 3; // 文案色值 string text_color = 4; // ? 新版本客户端已弃用此项 GradientColor bg_gradient_color = 5; // string img = 6; } // Dialog组件: 底部显示 message BottomDisplay { // 文案 TextInfo title = 1; // 图标 string icon = 2; } // 按钮信息 message ButtonInfo { // 按钮文案 string text = 1; // 按钮字体色值 string text_color = 2; // 按钮字体色值-夜间模式 string text_color_night = 3; // 按钮背景色 string bg_color = 4; // 按钮背景色-夜间模式 string bg_color_night = 5; // 按钮链接 string link = 6; // 按钮动作类型 string action_type = 7; // 角标信息 BadgeInfo badge_info = 8; // 埋点上报信息 Report report = 9; // 左侧删除线样式文案 string left_strikethrough_text = 10; // 缩略按钮文案信息 TextInfo simple_text_info = 11; // 缩略按钮背景色值 string simple_bg_color = 12; // 缩略按钮字体色值-夜间模式 string simple_bg_color_night = 13; // GradientColor bg_gradient_color = 14; // map order_report_params = 15; // TaskParam task_param = 16; // string pc_link = 17; } // 投屏限制. code = 0 时为无限制, 否则表示不不允许投屏并提示message message CastTips { // int32 code = 1; // string message = 2; } // 跳过片头/片尾配置 message ClipInfo { // int64 material_no = 1; // DASH分段始 int32 start = 2; // DASH分段终 int32 end = 3; // Clip类型 ClipType clip_type = 4; // 跳过片头/片尾时的提示语 string toast_text = 5; // MultiView multi_view = 6; } // 跳过片头/片尾配置: Clip类型 enum ClipType { NT_UNKNOWN = 0; // CLIP_TYPE_OP = 1; // 跳过OP CLIP_TYPE_ED = 2; // 跳过ED CLIP_TYPE_HE = 3; // CLIP_TYPE_MULTI_VIEW = 4; // CLIP_TYPE_AD = 5; // } // 编码类型 enum CodeType { NOCODE = 0; // 默认 CODE264 = 1; // H.264 CODE265 = 2; // H.265 } // message ContinuePlayInfo { // int64 continue_play_ep_id = 1; } // 优惠券 message Coupon { // 优惠券token string coupon_token = 1; // 优惠券类型 // 1:折扣券 2:满减券 3:兑换券 int64 type = 2; // 优惠券面值 string value = 3; // 优惠券使用描述 string use_desc = 4; // 优惠券标题 string title = 5; // 优惠券描述 string desc = 6; // 优惠券支付按钮文案 string pay_button_text = 7; // 优惠券支付按钮删除线文案 string pay_button_text_line_through = 8; // 实付金额 string real_amount = 9; // 使用过期时间 google.protobuf.Timestamp expire_time = 10; // int64 otype = 11; // string amount = 12; } // 优惠券信息 message CouponInfo { // 提示框信息 CouponToast toast = 1; // 弹窗信息 PopWin pop_win = 2; } // 优惠券提示框文案信息 message CouponTextInfo { // 提示框文案-播正片6分钟预览 string positive_preview = 1; // 提示框文案-播非正片分节ep string section = 2; } // 优惠券提示框信息 message CouponToast { // 提示框文案信息 CouponTextInfo text_info = 1; // 提示框按钮 ButtonInfo button = 2; } // dash条目 message DashItem { // 清晰度 uint32 id = 1; // 主线流 string base_url = 2; // 备用流 repeated string backup_url = 3; // 带宽 uint32 bandwidth = 4; // 编码id uint32 codecid = 5; // md5 string md5 = 6; // 视频大小 uint64 size = 7; // 帧率 string frame_rate = 8; // DRM widevine 密钥 string widevine_pssh = 9; } // dash视频流 message DashVideo { // 主线流 string base_url = 1; // 备用流 repeated string backup_url = 2; // 带宽 uint32 bandwidth = 3; // 编码id uint32 codecid = 4; // md5 string md5 = 5; // 大小 uint64 size = 6; // 伴音质量id uint32 audio_id = 7; // 是否非全二压 bool no_rexcode = 8; // 帧率 string frame_rate = 9; // 宽 int32 width = 10; // 高 int32 height = 11; // DRM 密钥 string widevine_pssh = 12; } // message DataControl { // bool need_watch_progress = 1; } // 鉴权浮层 message Dialog { // 鉴权限制码 int64 code = 1; // 鉴权限制信息 string msg = 2; // 浮层类型 string type = 3; // 浮层样式类型 string style_type = 4; // 浮层配置 DialogConfig config = 5; // 标题 TextInfo title = 6; // 副标题 TextInfo subtitle = 7; // 图片信息 ImageInfo image = 8; // 按钮列表 repeated ButtonInfo button = 9; // 底部描述 ButtonInfo bottom_desc = 10; // 埋点上报信息 Report report = 11; // 倒计时 秒 int32 count_down_sec = 12; // 右下描述 TextInfo right_bottom_desc = 13; // repeated BottomDisplay bottom_display = 14; // repeated PlayList play_list = 15; } // 鉴权浮层配置 message DialogConfig { // 是否显示高斯模糊背景图 bool is_show_cover = 1; // 是否响应转屏 bool is_orientation_enable = 2; // 是否响应上滑吸顶 bool is_nested_scroll_enable = 3; // 是否强制竖屏 bool is_force_halfscreen_enable = 4; // 是否启用背景半透明 bool is_background_translucent_enable = 5; } // 当前分辨率信息 message Dimension { // 宽 int32 width = 1; // 长 int32 height = 2; // 旋转角度 int32 rotate = 3; } // 杜比音频信息 message DolbyItem { // 杜比类型 enum Type { NONE = 0; // NONE COMMON = 1; // 普通杜比音效 ATMOS = 2; // 全景杜比音效 } // 杜比类型 Type type = 1; // 音频流 DashItem audio = 2; } // DRM技术类型 enum DrmTechType { NON = 0; // FAIR_PLAY = 1; // WIDE_VINE = 2; // BILI_DRM = 3; // } // 播放结束后的尾页Dialog message EndPage { // Dialog dialog = 1; // bool hide = 2; } // message EpInlineVideo { // int64 material_no = 1; // int64 aid = 2; // int64 cid = 3; } // 剧集广告信息 message EpisodeAdvertisementInfo { // int64 aid = 1; // string title = 2; // string link = 3; // int32 follow_video_bnt_flag = 4; // string next_video_title = 5; // string next_video_link = 6; // int64 cid = 7; // int32 season_id = 8; // int32 follow = 9; } // EP信息 message EpisodeInfo { // int32 ep_id = 1; // int64 cid = 2; // int64 aid = 3; // int64 ep_status = 4; // SeasonInfo season_info = 5; // string cover = 6; // string title = 7; // Interaction interaction = 8; // string long_title = 9; } // message EpPreVideo { // int64 aid = 1; // int64 cid = 2; } // message EpPublicityVideo { // enum Type { DATA_NOT_SET = 0; EP_PRE_VIDEO = 2; EP_INLINE = 3; } // Type type = 1; // oneof data { // EpPreVideo ep_pre_video = 2; // EpInlineVideo ep_inline_video = 3; } } // enum EpPublicityVideoType { // PRE = 0; // INLINE = 1; } // 事件 message Event { // 震动 Shake shake = 1; } // 放映室提示语 message FreyaConfig { // string desc = 1; // int32 type = 2; // int32 issued_cnt = 3; // bool is_always_show = 4; // int32 screen_number = 5; // int32 full_screen_number = 6; } // 渐变色信息 message GradientColor { // string start_color = 1; // string end_color = 2; } // 高画质试看信息 message HighDefinitionTrialInfo { // bool trial_able = 1; // int32 remaining_times = 2; // int32 start = 3; // int32 time_length = 4; // Toast start_toast = 5; // Toast end_toast = 6; // Report report = 7; // ButtonInfo quality_open_tip_btn = 8; // ButtonInfo no_longer_trial_btn = 9; } // 历史记录节点 message HistoryNode { // 节点ID int64 node_id = 1; // 节点标题 string title = 2; // 对应CID int64 cid = 3; } // 图片信息 message ImageInfo { // 图片链接 string url = 1; } // enum InlineScene { UNKNOWN = 0; // RELATED_EP = 1; // HE = 2; // SKIP = 3; // } // enum InlineType { TYPE_UNKNOWN = 0; // TYPE_WHOLE = 1; // TYPE_HE_CLIP = 2; // TYPE_PREVIEW = 3; // } // 交互信息 message Interaction { // 历史节点 HistoryNode history_node = 1; // 版本 int64 graph_version = 2; // 交互消息 string msg = 3; // 是否为交互 bool is_interaction = 4; } // 限制操作类型 enum LimitActionType { // LAT_UNKNOWN = 0; // SHOW_LIMIT_DIALOG = 1; // SKIP_CURRENT_EP = 2; } // message MultiView { // string button_material = 1; // int64 ep_id = 2; // int64 cid = 3; // int64 avid = 4; } // 大会员广告: 支付提示信息 message PayTip { // 标题 string title = 1; // 跳转链接 string url = 2; // 图标 string icon = 3; // 浮层类型 int32 type = 4; // 显示类型 int32 show_type = 5; // 图片信息 string img = 6; // 白天背景颜色 string bg_day_color = 7; // 夜间背景颜色 string bg_night_color = 8; // 白天线条颜色 string bg_line_color = 9; // 夜间线条颜色 string bg_night_line_color = 10; // 文字颜色 string text_color = 11; // 夜间文字颜色 string text_night_color = 12; // 视图展示起始时间 int64 view_start_time = 13; // 按钮列表 repeated ButtonInfo button = 14; // 跳转链接打开方式 int32 url_open_type = 15; // 埋点上报信息 Report report = 16; // 角度样式 int32 angle_style = 17; // 埋点上报类型 int32 report_type = 18; // 订单埋点上报参数 map order_report_params = 19; // 巨屏图片信息 string giant_screen_img = 20; } // 禁用功能配置 message PlayAbilityConf { bool background_play_disable = 1; // 后台播放 bool flip_disable = 2; // 镜像反转 bool cast_disable = 3; // 投屏 bool feedback_disable = 4; // 反馈 bool subtitle_disable = 5; // 字幕 bool playback_rate_disable = 6; // 播放速度 bool time_up_disable = 7; // 定时停止 bool playback_mode_disable = 8; // 播放方式 bool scale_mode_disable = 9; // 画面尺寸 bool like_disable = 10; // 赞 bool dislike_disable = 11; // 踩 bool coin_disable = 12; // 投币 bool elec_disable = 13; // 充电 bool share_disable = 14; // 分享 bool screen_shot_disable = 15; // 截图 bool lock_screen_disable = 16; // 锁定 bool recommend_disable = 17; // 相关推荐 bool playback_speed_disable = 18; // 播放速度 bool definition_disable = 19; // 清晰度 bool selections_disable = 20; // 选集 bool next_disable = 21; // 下一集 bool edit_dm_disable = 22; // 编辑弹幕 bool small_window_disable = 23; // 小窗 bool shake_disable = 24; // 震动 bool outer_dm_disable = 25; // 外层面板弹幕设置 bool inner_dm_disable = 26; // 三点内弹幕设置 bool freya_enter_disable = 27; // 一起看入口 bool dolby_disable = 28; // 杜比音效 bool freya_full_disable = 29; // 全屏一起看入口 bool skip_oped_switch_disable = 30; // 跳过片头片尾 bool record_screen_disable = 31; // 录屏 bool color_optimize_disable = 32; // 色觉优化 bool dubbing_disable = 33; // 配音 } // 云控扩展配置信息 message PlayAbilityExtConf { // bool allow_close_subtitle = 1; // FreyaConfig freya_config = 2; // CastTips cast_tips = 3; } // 播放配音信息 message PlayDubbingInfo { // 背景音频 AudioMaterialProto background_audio = 1; // 角色音频列表 repeated RoleAudioProto role_audio_list = 2; // 引导文本 string guide_text = 3; } // 错误码 enum PlayErr { NoErr = 0; // WithMultiDeviceLoginErr = 1; // 管控类型的错误码 } // 播放扩展信息 message PlayExtInfo { // 播放配音信息 PlayDubbingInfo play_dubbing_info = 1; } // message PlayList { // int32 season_id = 1; // string title = 2; // string cover = 3; // string link = 4; // BadgeInfo badge_info = 5; } // 其他业务信息 message PlayViewBusinessInfo { // 当前视频是否是预览 bool is_preview = 1; // 用户是否承包过 bool bp = 2; // drm使用 string marlin_token = 3; // 倍速动效色值 string playback_speed_color = 4; // ContinuePlayInfo continue_play_info = 5; // 跳过片头/片尾配置 repeated ClipInfo clip_info = 6; // InlineType inline_type = 7; // int32 ep_whole_duration = 8; // 当前分辨率信息 Dimension dimension = 9; // map quality_ext_map = 10; // map exp_map = 11; // DRM技术类型 DrmTechType drm_tech_type = 12; // int32 limit_action_type = 13; // bool is_drm = 14; // RecordInfo record_info = 15; // int32 vip_status = 16; // bool is_live_pre = 17; // EpisodeInfo episode_info = 18; // EpisodeAdvertisementInfo episode_advertisement_info = 19; // UserStatus user_status = 20; } // 播放页信息-响应 message PlayViewReply { // 视频流信息 VideoInfo video_info = 1; // 播放控件用户自定义配置 PlayAbilityConf play_conf = 2; // 业务需要的其他信息 PlayViewBusinessInfo business = 3; // 事件 Event event = 4; // 展示信息 ViewInfo view_info = 5; // 自定义配置扩展信息 PlayAbilityExtConf play_ext_conf = 6; // 播放扩展信息 PlayExtInfo play_ext_info = 7; } // 播放页信息-请求 message PlayViewReq { // 剧集epid int64 epid = 1; // 视频cid int64 cid = 2; // 清晰度 int64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 当前页spm string spmid = 9; // 上一页spm string from_spmid = 10; // 青少年模式 int32 teenagers_mode = 11; // 视频编码 CodeType prefer_codec_type = 12; // 是否强制请求预览视频 bool is_preview = 13; // 一起看房间id int64 room_id = 14; // 是否需要展示信息 bool is_need_view_info = 15; // 场景控制 SceneControl scene_control = 16; // InlineScene inline_scene = 17; // int64 material_no = 18; // DRM 安全等级 int32 security_level = 19; // int64 season_id = 20; // DataControl data_control = 21; } // 弹窗信息 message PopWin { // 弹窗标题 老字段 string title = 1; // 优惠券列表 repeated Coupon coupon = 2; // 弹窗按钮列表 repeated ButtonInfo button = 3; // 底部文案 老字段 string bottom_text = 4; // 弹窗标题 新字段 TextInfo pop_title = 5; // 弹窗副标题 TextInfo subtitle = 6; // 底部描述 新字段 ButtonInfo bottom_desc = 7; // 弹窗小图 string cover = 8; // 弹窗类型 string pop_type = 9; } // 广告组件: 竖屏时视频下部提示栏 message PromptBar { // 主标题, 如: "本片含大会员专享内容" TextInfo title = 1; // 副标题, 如: "成为大会员可免费看全部剧集" TextInfo sub_title = 2; // 副标题前面的icon string sub_title_icon = 3; // 背景图 string bg_image = 4; // 背景渐变色 GradientColor bg_gradient_color = 5; // 按钮 repeated ButtonInfo button = 6; // 埋点上报信息 Report report = 7; // string full_screen_ip_icon = 8; // GradientColor full_screen_bg_gradient_color = 9; } // 云控拓展视频画质信息 message QualityExtInfo { // 是否支持试看 bool trial_support = 1; } // 备案信息 message RecordInfo { // 记录 string record = 1; // 记录图标 string record_icon = 2; } // 埋点上报信息 message Report { // 曝光事件 string show_event_id = 1; // 点击事件 string click_event_id = 2; // 埋点透传参数 string extends = 3; } // 分段流条目 message ResponseUrl { // 分段序号 uint32 order = 1; // 分段时长 uint64 length = 2; // 分段大小 uint64 size = 3; // 主线流 string url = 4; // 备用流 repeated string backup_url = 5; // md5 string md5 = 6; } // 权限信息 message Rights { // 是否可以观看 int32 can_watch = 1; } // 角色配音信息 message RoleAudioProto { // 角色ID int64 role_id = 1; // 角色名称 string role_name = 2; // 角色头像 string role_avatar = 3; // 音频素材列表 repeated AudioMaterialProto audio_material_list = 4; } // 场景控制 message SceneControl { // 是否收藏播单 bool fav_playlist = 1; // 是否小窗 bool small_window = 2; // 是否画中画 bool pip = 3; // bool was_he_inline = 4; // bool is_need_trial = 5; } // 方案 message Scheme { enum ActionType { UNKNOWN = 0; SHOW_TOAST = 1; } // ActionType action_type = 1; // string toast = 2; } // PGC SEASON 信息 message SeasonInfo { // PGC SEASON ID int32 season_id = 1; // PGC SEASON 类型 int32 season_type = 2; // PGC SEASON 状态 int32 season_status = 3; // 封面 string cover = 4; // 标题 string title = 5; // 权限信息 Rights rights = 6; // 模式 int32 mode = 7; } // DRM 安全等级 enum SecurityLevel { LEVEL_UNKNOWN = 0; // LEVEL_L1 = 1; // LEVEL_L2 = 2; // LEVEL_L3 = 3; // } // 分段视频流 message SegmentVideo { //分段视频流列表 repeated ResponseUrl segment = 1; } // 震动 message Shake { // 文件地址 string file = 1; } // 视频流信息 message Stream { // 元数据 StreamInfo info = 1; // 流数据 oneof content { // dash流 DashVideo dash_video = 2; // 分段流 SegmentVideo segment_video = 3; } } // 流媒体元数据 message StreamInfo { // 视频质量 int32 quality = 1; // 视频格式 string format = 2; // 描述信息 string description = 3; // 错误码 int32 err_code = 4; // 流限制信息 StreamLimit limit = 5; // 是否需要VIP bool need_vip = 6; // 是否需要登录 bool need_login = 7; // 是否完整 bool intact = 8; // 权限信息 int64 attribute = 10; // 新版描述信息 string new_description = 11; // 显示描述信息 string display_desc = 12; // 上标 string superscript = 13; // 方案信息 Scheme scheme = 14; // 是否支持DRM bool support_drm = 15; // 字幕信息 string subtitle = 16; } // 清晰度不满足条件信息 message StreamLimit { // 标题 string title = 1; // 跳转地址 string uri = 2; // 提示信息 string msg = 3; } // 任务参数信息 message TaskParam { // 任务类型 string task_type = 1; // 活动ID int64 activity_id = 2; // 提示ID int64 tips_id = 3; } // 文案信息 message TextInfo { // 文案 string text = 1; // 字体色值 string text_color = 2; // 字体色值-夜间模式 string text_color_night = 3; } // toast message Toast { // toast文案 老字段 string text = 1; // toast按钮 ButtonInfo button = 2; // 显示样式类型 int32 show_style_type = 3; // 图标 string icon = 4; // toast文案 新字段 TextInfo toast_text = 5; // 埋点上报信息 Report report = 6; // map order_report_params = 7; } // 用户状态信息 message UserStatus { // 是否支付 bool pay_check = 1; // 是否承包 bool sponsor = 2; // 观看进度 WatchProgress watch_progress = 3; // 系列观看进度 WatchProgress aid_watch_progress = 4; } // 视频url信息 message VideoInfo { // 视频清晰度 uint32 quality = 1; // 视频格式 string format = 2; // 视频时长 uint64 timelength = 3; // 视频编码id uint32 video_codecid = 4; // 视频流 repeated Stream stream_list = 5; // 伴音流 repeated DashItem dash_audio = 6; // 杜比伴音流 DolbyItem dolby = 7; } // 展示信息 message ViewInfo { // 弹窗 Dialog dialog = 1; // Toast Toast toast = 2; // 优惠券信息 CouponInfo coupon_info = 3; // 未支付剧集ID列表 repeated int64 demand_no_pay_epids = 4; // 结束页 EndPage end_page = 5; // 扩展配置 map exp_config = 6; // 弹窗 PopWin pop_win = 7; // 试看提示栏 PromptBar try_watch_prompt_bar = 8; // 支付提示信息 PayTip pay_tip = 9; // 高清试看提示信息 HighDefinitionTrialInfo high_definition_trial_info = 10; // 弹窗扩展 map ext_dialog = 11; // 动画 Animation animation = 12; // Toast扩展 map ext_toast = 13; } // 观看进度信息 message WatchProgress { // 上次观看的 EP ID int32 last_ep_id = 1; // 上次观看到的EP INDEX string last_ep_index = 2; // 上次观看的进度 int64 progress = 3; // 上次观看的 CID int64 last_play_cid = 4; // 带时间的提示信息 Toast toast = 5; // 不带时间的提示信息 Toast toast_without_time = 6; // 上次观看的 AID int64 last_play_aid = 7; } ================================================ FILE: bili-api/grpc/proto/bilibili/pgc/service/premiere/v1/premiere.proto ================================================ syntax = "proto3"; package bilibili.pgc.service.premiere.v1; option java_multiple_files = true; // 首播服务 service Premiere { // 获取首播状态 rpc Status (PremiereStatusReq) returns (PremiereStatusReply); } // 获取首播状态-请求 message PremiereStatusReq { // 剧集epid int64 ep_id = 1; } // 获取首播状态-响应 message PremiereStatusReply { // 服务端播放进度 单位ms 用户实际播放进度:progress - delay_time int64 progress = 1; // 起播时间戳 单位ms int64 start_time = 2; // 延迟播放时长 单位ms int64 delay_time = 3; // 首播在线人数 int64 online_count = 4; // 首播状态 // 1:预热 2:首播中 3:紧急停播 4:已结束 int32 status = 5; // 首播结束后跳转类型 // 1:下架 2:转点播 int32 after_premiere_type = 6; } ================================================ FILE: bili-api/grpc/proto/bilibili/playershared/playershared.proto ================================================ syntax = "proto3"; package bilibili.playershared; option java_multiple_files = true; // ArcConf消息 message ArcConf { // 是否支持 bool is_support = 1; // 是否禁用 bool disabled = 2; // 额外内容 ExtraContent extra_content = 3; // 不支持场景列表 repeated int32 unsupport_scene = 4; } // enum ArcType { // ARC_TYPE_NORMAL = 0; // ARC_TYPE_INTERACT = 1; } // message BackgroundInfo { // string drawable_color = 1; // string drawable_bitmap_url = 2; // int32 effects = 3; } // message BadgeInfo { // string text = 1; // string bg_color = 2; // string bg_color_night = 3; // string text_color = 4; // GradientColor bg_gradient_color = 5; // string img = 6; } // enum BizType { // BIZ_TYPE_UNKNOWN = 0; // BIZ_TYPE_UGC = 1; // BIZ_TYPE_PGC = 2; // BIZ_TYPE_PUGV = 3; } // message BottomDisplay { // TextInfo title = 1; // string icon = 2; } // 按钮组件 message Button { // 按钮文本 string text = 1; // 按钮跳转链接 string link = 2; // 埋点上报相关 map report_params = 3; } enum ButtonAction { // BUTTON_UNKNOWN = 0; // PAY = 1; // VIP = 2; // PACK = 3; // LINK = 4; // COUPON = 5; // DEMAND = 6; // DEMAND_PACK = 7; // FOLLOW = 8; // APPOINTMENT = 9; // VIP_FREE = 10; // TASK = 11; // CHARGINGPLUS = 12; // BP = 13; // PRE_SELL = 14; } // message ButtonInfo { // string text = 1; // string text_color = 2; // string text_color_night = 3; // string bg_color = 4; // string bg_color_night = 5; // string link = 6; // ButtonAction action_type = 7; // BadgeInfo badge_info = 8; // Report report = 9; // string left_strikethrough_text = 10; // TextInfo simple_text_info = 11; // string simple_bg_color = 12; // string simple_bg_color_night = 13; // GradientColor bg_gradient_color = 14; // map order_report_params = 15; // TaskParam task_param = 16; // string frame_color = 17; // string icon = 18; } // 视频编码 enum CodeType { NOCODE = 0; // 不指定 CODE264 = 1; // H264 CODE265 = 2; // H265 CODEAV1 = 3; // AV1 } // message ComprehensiveToast { // int32 type = 1; // ButtonInfo button = 2; // int32 show_style_type = 3; // string icon = 4; // TextInfo toast_text = 5; // Report report = 6; // map order_report_params = 7; } // 功能类型 enum ConfType { NoType = 0; FLIPCONF = 1; CASTCONF = 2; FEEDBACK = 3; SUBTITLE = 4; PLAYBACKRATE = 5; TIMEUP = 6; PLAYBACKMODE = 7; SCALEMODE = 8; BACKGROUNDPLAY = 9; LIKE = 10; DISLIKE = 11; COIN = 12; ELEC = 13; SHARE = 14; SCREENSHOT = 15; LOCKSCREEN = 16; RECOMMEND = 17; PLAYBACKSPEED = 18; DEFINITION = 19; SELECTIONS = 20; NEXT = 21; EDITDM = 22; SMALLWINDOW = 23; SHAKE = 24; OUTERDM = 25; INNERDM = 26; PANORAMA = 27; DOLBY = 28; COLORFILTER = 29; LOSSLESS = 30; FREYAENTER = 31; FREYAFULLENTER = 32; SKIPOPED = 33; RECORDSCREEN = 34; DUBBING = 35; LISTEN = 36; } // message ConfValue { oneof value { // int32 switch_val = 1; // int32 selected_val = 2; } } // Dash条目 message DashItem { // 清晰度 uint32 id = 1; // 主线流 string base_url = 2; // 备用流 repeated string backup_url = 3; // 带宽 uint32 bandwidth = 4; // 编码id uint32 codecid = 5; // md5 string md5 = 6; // 大小 uint64 size = 7; // 帧率 string frame_rate = 8; // DRM密钥 string widevine_pssh = 9; } // 视频流信息: dash流 message DashVideo { // 主线流 string base_url = 1; // 备用流 repeated string backup_url = 2; // 带宽 uint32 bandwidth = 3; // 编码id uint32 codecid = 4; // md5 string md5 = 5; // 大小 uint64 size = 6; // 伴音质量id uint32 audio_id = 7; // 是否非全二压 bool no_rexcode = 8; // 帧率 string frame_rate = 9; // 宽 int32 width = 10; // 高 int32 height = 11; // DRM密钥 string widevine_pssh = 12; } // message DeviceConf { ConfValue conf_value = 1; } // message Dialog { // int32 style_type = 1; // BackgroundInfo background_info = 2; // TextInfo title = 3; // TextInfo subtitle = 4; // ImageInfo image = 5; // repeated ButtonInfo button = 6; // ButtonInfo bottom_desc = 7; // Report report = 8; // int32 count_down_sec = 9; // TextInfo right_bottom_desc = 10; // repeated BottomDisplay bottom_display = 11; // ExtData ext_data = 12; // int32 limit_action_type = 13; } // 当前分辨率信息 message Dimension { // 宽 int32 width = 1; // 长 int32 height = 2; // 旋转角度 int32 rotate = 3; } // 杜比伴音流信息 message DolbyItem { // 杜比类型 enum Type { NONE = 0; // NONE COMMON = 1; // 普通杜比音效 ATMOS = 2; // 全景杜比音效 } // 杜比类型 Type type = 1; // 音频流 repeated DashItem audio = 2; } // DRM类型 enum DrmTechType { // UNKNOWN_DRM = 0; // FAIR_PLAY = 1; // WIDE_VINE = 2; // 哔哩哔哩自研DRM BILI_DRM = 3; } enum Effects { // EFFECTS_UNKNOWN = 0; // GAUSSIAN_BLUR = 1; // HALF_ALPHA = 2; } // 事件 message Event { // 震动 Shake shake = 1; } // message ExtData { // ExtDataType type = 1; // oneof data { PlayListInfo play_list_info = 2; } } enum ExtDataType { // EXT_DATA_TYPE_UNKNOWN = 0; // PLAY_LIST = 1; } // ? 错误码补充信息 message ExtraContent { // string disable_reason = 1; // int64 disable_code = 2; } // message GradientColor { // string start_color = 1; // string end_color = 2; } // enum GuideStyle { // STYLE_UNKNOWN = 0; // HORIZONTAL_IMAGE = 1; // VERTICAL_TEXT = 2; // SIMPLE_TEXT = 3; // CHARGING_TEXT = 4; } // 播放历史 message History { // HistoryInfo current_video = 1; // HistoryInfo related_video = 2; } // message HistoryInfo { // int64 progress = 1; // int64 last_play_cid = 2; // Toast toast = 3; // Toast toast_without_time = 4; // int64 last_play_aid = 5; } // message ImageInfo { // string url = 1; } // message Interaction { // Node history_node = 1; // int64 graph_version = 2; // string msg = 3; // int64 mark = 4; } enum LimitActionType { // LAT_UNKNOWN = 0; // SHOW_LIMIT_DIALOG = 1; // SKIP_CURRENT_EP = 2; } // HIRES伴音流信息 message LossLessItem { // 是否为hires bool is_lossless_audio = 1; // 音频流信息 DashItem audio = 2; // 是否需要大会员 bool need_vip = 3; } // message Node { // int64 node_id = 1; // string title = 2; // int64 cid = 3; } // message PlayArc { // BizType video_type = 1; // uint64 aid = 2; // uint64 cid = 3; // DrmTechType drm_tech_type = 4; // ArcType arc_type = 5; // Interaction interaction = 6; // Dimension dimension = 7; // int64 duration = 8; // bool is_preview = 9; } // 播放页信息-响应: PlayArcConf message PlayArcConf { map arc_confs = 1; } // message PlayDeviceConf { // map device_confs = 1; } // 错误码 enum PlayErr { NoErr = 0; // WithMultiDeviceLoginErr = 1; // 管控类型的错误码 } // message PlayList { // int64 season_id = 1; // string title = 2; // string cover = 3; // string link = 4; // BadgeInfo badge_info = 5; } // message PlayListInfo { // repeated PlayList play_list = 2; } // 视频下方广告 Banner message PromptBar { // TextInfo title = 1; // TextInfo subtitle = 2; // string sub_title_icon = 3; // string bg_image = 4; // GradientColor bg_gradient_color = 5; // repeated ButtonInfo button = 6; // Report report = 7; // string full_screen_ip_icon = 8; // GradientColor full_screen_bg_gradient_color = 9; } // 播放页信息-响应: 高画质试看信息 message QnTrialInfo { // 能否试看高画质 bool trial_able = 1; // int32 remaining_times = 2; // int32 start = 3; // int32 time_length = 4; // Toast start_toast = 5; // Toast end_toast = 6; // Button quality_open_tip_btn = 8; } // message Report { // string show_event_id = 1; // string click_event_id = 2; // string extends = 3; } // Dash Response, 未使用 message ResponseDash { repeated DashItem video = 1; repeated DashItem audio = 2; } // 分段流条目 message ResponseUrl { // 分段序号 uint32 order = 1; // 分段时长 uint64 length = 2; // 分段大小 uint64 size = 3; // 主线流 string url = 4; // 备用流 repeated string backup_url = 5; // md5 string md5 = 6; } // 方案 message Scheme { enum ActionType { UNKNOWN = 0; SHOW_TOAST = 1; } // ActionType action_type = 1; // string toast = 2; } // 视频流信息: 分段流 message SegmentVideo { repeated ResponseUrl segment = 1; } // 震动 message Shake { // string file = 1; } enum ShowStyleType { // SHOW_STYLE_TYPE_UNKNOWN = 0; // SHOW_STYLE_TYPE_ORDINARY = 1; // SHOW_STYLE_TYPE_RESIDENT = 2; } // 视频流信息 message Stream { // 元数据 StreamInfo stream_info = 1; // 流数据 oneof content { // dash流 DashVideo dash_video = 2; // 分段流 SegmentVideo segment_video = 3; } } // 视频流信息: 元数据 message StreamInfo { // 清晰度 uint32 quality = 1; // 格式 string format = 2; // 格式描述 string description = 3; // 错误码 uint32 err_code = 4; // 不满足条件信息 StreamLimit limit = 5; // 是否需要vip bool need_vip = 6; // 是否需要登录 bool need_login = 7; // 是否完整 bool intact = 8; // 是否非全二压 bool no_rexcode = 9; // 清晰度属性位 int64 attribute = 10; // 新版格式描述 string new_description = 11; // 格式文字 string display_desc = 12; // 新版格式描述备注 string superscript = 13; // bool vip_free = 14; // string subtitle = 15; // 方案 Scheme scheme = 16; // 支持drm bool support_drm = 17; } // 视频流信息: 流媒体元数据: 清晰度不满足条件信息 message StreamLimit { // 标题 string title = 1; // 跳转地址 string uri = 2; // 提示信息 string msg = 3; } // message TaskParam { // string task_type = 1; // int64 activity_id = 2; // int64 tips_id = 3; } // message TextInfo { // string text = 1; // string text_color = 2; // string text_color_night = 3; } // Toast信息 message Toast { // toast文案 string text = 1; // toast按钮 Button button = 2; } enum ToastType { // TOAST_TYPE_UNKNOWN = 0; // VIP_CONTENT_REMIND = 1; // VIP_DEFINITION_REMIND = 2; // VIP_DEFINITION_GUIDE = 3; // OGV_VIDEO_START_TOAST = 4; // CHARGING_TOAST = 5; } // enum UnsupportScene { // UNKNOWN_SCENE = 0; // PREMIERE = 1; } // 播放页信息-请求: 音视频VOD message VideoVod { // 视频aid int64 aid = 1; // 视频cid int64 cid = 2; // 清晰度 uint64 qn = 3; // 视频流版本 int32 fnver = 4; // 视频流格式 int32 fnval = 5; // 下载模式 // 0:播放 1:flv下载 2:dash下载 uint32 download = 6; // 流url强制是用域名 // 0:允许使用ip 1:使用http 2:使用https int32 force_host = 7; // 是否4K bool fourk = 8; // 视频编码 CodeType prefer_codec_type = 9; // 响度均衡 uint64 voice_balance = 10; } message ViewInfo { // map dialog_map = 1; // PromptBar prompt_bar = 2; // repeated ComprehensiveToast toasts = 3; } // 播放页信息-响应: VOD音视频信息 message VodInfo { // 视频清晰度 uint32 quality = 1; // 视频格式 string format = 2; // 视频时长 uint64 timelength = 3; // 视频编码id uint32 video_codecid = 4; // 视频流 repeated Stream stream_list = 5; // 伴音流 repeated DashItem dash_audio = 6; // 杜比伴音流 DolbyItem dolby = 7; // 响度均衡操作信息 VolumeInfo volume = 8; // HIRES伴音流信息 LossLessItem loss_less_item = 9; // 是否支持投屏 bool support_project = 10; } // 响度均衡操作信息 message VolumeInfo { // Measured integrated loudness 实际综合响度 double measured_i = 1; // Measured loudness range 实际响度范围 double measured_lra = 2; // Measured true peak 实际响度真峰值 double measured_tp = 3; // Measured threshold 实际响度阈值 double measured_threshold = 4; // Target offset gain(Gain is applied before the true-peak limiter) 目标增益Offset(增益在真实峰值限制器之前应用) double target_offset = 5; // Target integrated loudness 目标综合响度 double target_i = 6; // Target true peak 目标响度真峰值 double target_tp = 7; } ================================================ FILE: bili-api/grpc/proto/bilibili/polymer/app/search/v1/search.proto ================================================ syntax = "proto3"; package bilibili.polymer.app.search.v1; option java_multiple_files = true; import "bilibili/app/archive/middleware/v1/preload.proto"; import "bilibili/pagination/pagination.proto"; // service Search { // 搜索所有类型结果 rpc SearchAll(SearchAllRequest) returns (SearchAllResponse); // 搜索指定类型结果 rpc SearchByType(SearchByTypeRequest) returns (SearchByTypeResponse); // rpc SearchComic(SearchComicRequest) returns (SearchComicResponse); } // message Args { // int32 online = 1; // string rname = 2; // int64 room_id = 3; // string tname = 4; // int64 up_id = 5; // string up_name = 6; // int64 rid = 7; // int64 tid = 8; // int64 aid = 9; } // message Avatar { // string cover = 1; // string event = 2; // string event_v2 = 3; // string text = 4; // int64 up_id = 5; // string uri = 6; // int32 face_nft_new = 7; // NftFaceIcon nft_face_icon = 8; } // message AvItem { // string title = 1; // string cover = 2; // string uri = 3; // string ctime_label = 4; // string duration = 5; // int64 play = 6; // int64 danmaku = 7; // int32 ctime = 8; // string goto = 9; // string param = 10; // int32 position = 11; // string ctime_label_v2 = 12; } // message Background { // int32 show = 1; // string bg_pic_url = 2; // string fg_pic_url = 3; } // message Badge { // string text = 1; // string bg_cover = 2; } // message Badge2 { // string bg_cover = 1; // string text = 2; } // message BottomButton { // string desc = 1; // string link = 2; } // message BrandADAccount { // string param = 1; // string goto = 2; // int64 mid = 3; // string name = 4; // string face = 5; // string sign = 6; // Relation relation = 7; // int64 roomid = 8; // int64 live_status = 9; // string live_link = 10; // OfficialVerify official_verify = 11; // VipInfo vip = 12; // string uri = 13; // int32 face_nft_new = 14; } // message BrandADArc { // string param = 1; // string goto = 2; // int64 aid = 3; // int64 play = 4; // int64 reply = 5; // string duration = 6; // string author = 7; // string title = 8; // string uri = 9; // string cover = 10; } // message Button { // string text = 1; // string param = 2; // string uri = 3; // string event = 4; // int32 selected = 5; // int32 type = 6; // string event_v2 = 7; // Relation relation = 8; } // message ButtonMeta { // string icon = 1; // string text = 2; // string button_status = 3; // string toast = 4; } // message CardBusinessBadge { // GotoIcon goto_icon = 1; // ReasonStyle badge_style = 2; } // enum CategorySort { CATEGORY_SORT_DEFAULT = 0; // CATEGORY_SORT_PUBLISH_TIME = 1; // CATEGORY_SORT_CLICK_COUNT = 2; // CATEGORY_SORT_COMMENT_COUNT = 3; // CATEGORY_SORT_LIKE_COUNT = 4; // } // message ChannelLabel { // string text = 1; // string uri = 2; } // message ChannelMixedItem { // int64 id = 1; // int32 cover_left_icon1 = 2; // string cover_left_text1 = 3; // string cover = 4; // string goto = 5; // string param = 6; // string uri = 7; // string title = 8; // Badge2 badge = 9; } // message CheckMore { // string content = 1; // string uri = 2; } // message CloudGameParams { // int64 source_from = 1; // string scene = 2; } // message DetailsRelationItem { // string title = 1; // string cover = 2; // string cover_left_text = 3; // ReasonStyle cover_badge_style = 4; // string module_pos = 5; // string goto = 6; // string param = 7; // string uri = 8; // int32 position = 9; // string cover_left_text_v2 = 10; // ReasonStyle cover_badge_style_v2 = 11; } // message DislikeReason { // int32 id = 1; // string name = 2; } // message DisplayOption { // int32 video_title_row = 1; // int32 search_page_visual_opti = 2; } // message DyTopic { // string title = 1; // string uri = 2; } // message EasterEgg { // int32 id = 1; // int32 show_count = 2; // int32 type = 3; // string url = 4; // int32 close_count = 5; // int32 mask_transparency = 6; // string mask_color = 7; // int32 pic_type = 8; // int32 show_time = 9; // string source_url = 10; // string source_md5 = 11; // int32 source_size = 12; } // message Episode { // string uri = 1; // string param = 2; // string index = 3; // repeated ReasonStyle badges = 4; // int32 position = 5; } // message EpisodeNew { // string title = 1; // string uri = 2; // string param = 3; // int32 is_new = 4; // repeated ReasonStyle badges = 5; // int32 type = 6; // int32 position = 7; // string cover = 8; // string label = 9; } // message ExtraLink { // string text = 1; // string uri = 2; } // message FollowButton { // string icon = 1; // map texts = 2; // string status_report = 3; } // message FullTextResult { // int32 type = 1; // string show_text = 2; // int64 jump_start_progress = 3; // string jump_uri = 4; } // message GotoIcon { // string icon_url = 1; // string icon_night_url = 2; // int32 icon_width = 3; // int32 icon_height = 4; } // message InlineProgressBar { // string icon_drag = 1; // string icon_drag_hash = 2; // string icon_stop = 3; // string icon_stop_hash = 4; } // message InlineThreePointPanel { // int32 panel_type = 1; // string share_id = 2; // string share_origin = 3; // repeated ShareButtonItem functional_buttons = 4; } message Item { // string uri = 1; // string param = 2; // string goto = 3; // string linktype = 4; // int32 position = 5; // string trackid = 6; // oneof card_item { // SearchSpecialCard special = 7; // SearchArticleCard article = 8; // SearchBannerCard banner = 9; // SearchLiveCard live = 10; // SearchGameCard game = 11; // SearchPurchaseCard purchase = 12; // SearchRecommendWordCard recommend_word = 13; // SearchDynamicCard dynamic = 14; // SearchNoResultSuggestWordCard suggest_keyword = 15; // SearchSpecialGuideCard special_guide = 16; // SearchComicCard comic = 17; // SearchNewChannelCard channel_new = 18; // SearchOgvCard ogv_card = 19; // SearchOgvRelationCard bangumi_relates = 20; // SearchOgvRecommendCard find_more = 21; // SearchSportCard esport = 22; // SearchAuthorNewCard author_new = 23; // SearchTipsCard tips = 24; // SearchAdCard cm = 25; // SearchPediaCard pedia_card = 26; // SearchUgcInlineCard ugc_inline = 27; // SearchLiveInlineCard live_inline = 28; // SearchTopGameCard top_game = 29; // SearchOlympicGameCard sports = 30; // SearchOlympicWikiCard pedia_card_inline = 31; // SearchRecommendTipCard recommend_tips = 32; // SearchCollectionCard collection_card = 33; // SearchOgvChannelCard ogv_channel = 34; // SearchOgvInlineCard ogv_inline = 35; // SearchUpperCard author = 36; // SearchVideoCard av = 37; // SearchBangumiCard bangumi = 38; // SearchSportInlineCard esports_inline = 39; } } // message LikeResource { // string url = 1; // string content_hash = 2; } // message LiveBadgeResource { // string text = 1; // string animation_url = 2; // string animation_url_hash = 3; // string background_color_light = 4; // string background_color_night = 5; // int64 alpha_light = 6; // int64 alpha_night = 7; // string font_color = 8; } // message Mask { // Avatar avatar = 1; // Button button = 2; } // message MatchInfoObj { // int64 id = 1; // int32 status = 2; // string match_stage = 3; // MatchTeam team1 = 4; // MatchTeam team2 = 5; // MatchItem match_label = 6; // MatchItem match_time = 7; // MatchItem match_button = 8; } // message MatchItem { // int32 state = 1; // string text = 2; // string text_color = 3; // string text_color_night = 4; // string uri = 5; // string live_link = 6; // Texts texts = 7; } // message MatchTeam { // int64 id = 1; // string title = 2; // string cover = 3; // int32 score = 4; } // message Nav { // string name = 1; // int32 total = 2; // int32 pages = 3; // int32 type = 4; } // message Navigation { // int64 id = 1; // repeated Navigation children = 2; // repeated Navigation inline_children = 3; // string title = 4; // string uri = 5; // NavigationButton button = 6; } // message NavigationButton { // int64 type = 1; // string text = 2; // string uri = 3; } // message NftFaceIcon { // int32 region_type = 1; // string icon = 2; // int32 show_status = 3; } // message Notice { // int64 mid = 1; // int64 notice_id = 2; // string content = 3; // string url = 4; // int64 notice_type = 5; // string icon = 6; // string icon_night = 7; // string text_color = 8; // string text_color_night = 9; // string bg_color = 10; // string bg_color_night = 11; } // message OfficialVerify { // int32 type = 1; // string desc = 2; } // message OgvCardUI { // string background_image = 1; // string gaussian_blur_value = 2; // string module_color = 3; } // message OgvClipInfo { // int64 play_start_time = 1; // int64 play_end_time = 2; } // message OgvRecommendWord { // string title = 1; // string goto = 2; // string param = 3; // string uri = 4; } // message PediaCover { // int64 cover_type = 1; // string cover_sun_url = 2; // string cover_night_url = 3; // int32 cover_width = 4; // int32 cover_height = 5; } // message PlayerArgs { // int32 is_live = 1; // int64 aid = 2; // int64 cid = 3; // int32 sub_type = 4; // int64 room_id = 5; // int64 ep_id = 7; // int32 is_preview = 8; // string type = 9; // int32 duration = 10; // int64 season_id = 11; // int32 report_required_play_duration = 12; // int32 report_required_time = 13; // int32 manual_play = 14; // bool hide_play_button = 15; // int32 content_mode = 16; // int32 report_history = 17; } // message PlayerWidget { // string title = 1; // string desc = 2; } // message RankInfo { // string search_night_icon_url = 1; // string search_day_icon_url = 2; // string search_bkg_night_color = 3; // string search_bkg_day_color = 4; // string search_font_night_color = 5; // string search_font_day_color = 6; // string rank_content = 7; // string rank_link = 8; } // message RcmdReason { // string content = 1; } // message ReasonStyle { // string text = 1; // string text_color = 2; // string text_color_night = 3; // string bg_color = 4; // string bg_color_night = 5; // string border_color = 6; // string border_color_night = 7; // int32 bg_style = 8; } // message RecommendWord { // string param = 1; // string type = 2; // string title = 3; // string from_source = 4; } // message Relation { // int32 status = 1; } // message RightTopLiveBadge { // int32 live_status = 1; // LiveBadgeResource in_live = 2; // string live_stats_desc = 3; } // message SearchAdCard { // string json_str = 1; } // message SearchAllRequest { // string keyword = 1; // int32 order = 2; // string tid_list = 3; // string duration_list = 4; // string extra_word = 5; // string from_source = 6; // int32 is_org_query = 7; // int32 local_time = 8; // string ad_extra = 9; // bilibili.pagination.Pagination pagination = 10; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 11; } // message SearchAllResponse { // string keyword = 1; // string trackid = 2; // repeated Nav nav = 3; // repeated Item item = 4; // EasterEgg easter_egg = 5; // string exp_str = 6; // repeated string extra_word_list = 7; // string org_extra_word = 8; // int64 select_bar_type = 9; // int64 new_search_exp_num = 10; // bilibili.pagination.PaginationReply pagination = 11; // DisplayOption app_display_option = 12; // map annotation = 13; } // message SearchArticleCard { // string title = 1; // string cover = 2; // int64 play = 3; // int32 like = 4; // int32 reply = 5; // repeated string image_urls = 6; // string author = 7; // int32 template_id = 8; // int64 id = 9; // int64 mid = 10; // string name = 11; // string desc = 12; // int64 view = 13; } // message SearchAuthorNewCard { // string title = 1; // string cover = 2; // int32 live_face = 3; // string live_uri = 4; // string live_link = 5; // int32 fans = 6; // int32 level = 7; // string sign = 8; // bool is_up = 9; // int32 archives = 10; // int64 mid = 11; // int64 roomid = 12; // Relation relation = 13; // OfficialVerify official_verify = 14; // int32 face_nft_new = 15; // NftFaceIcon nft_face_icon = 16; // int32 is_senior_member = 17; // Background background = 18; // int32 av_style = 19; // Space space = 20; // repeated AvItem av_items = 21; // Notice notice = 22; // SharePlane share_plane = 23; // string inline_type = 24; // SearchInlineData inline_live = 25; // int32 is_inline_live = 26; // repeated ThreePoint three_point = 27; // int32 live_status = 28; // VipInfo vip = 29; } // message SearchBangumiCard { // string title = 1; // string cover = 2; // int32 media_type = 3; // int32 play_state = 4; // string area = 5; // string style = 6; // string styles = 7; // string cv = 8; // double rating = 9; // int32 vote = 10; // string target = 11; // string staff = 12; // string prompt = 13; // int64 ptime = 14; // string season_type_name = 15; // repeated Episode episodes = 16; // int32 is_selection = 17; // int32 is_atten = 18; // string label = 19; // int64 season_id = 20; // string out_name = 21; // string out_icon = 22; // string out_url = 23; // repeated ReasonStyle badges = 24; // int32 is_out = 25; // repeated EpisodeNew episodes_new = 26; // WatchButton watch_button = 27; // string selection_style = 28; // CheckMore check_more = 29; // FollowButton follow_button = 30; // ReasonStyle style_label = 31; // repeated ReasonStyle badges_v2 = 32; // string styles_v2 = 33; } // message SearchBannerCard { // string title = 1; // string cover = 2; } // message SearchByTypeRequest { enum CategorySort { CATEGORY_SORT_DEFAULT = 0; CATEGORY_SORT_PUBLISH_TIME = 1; CATEGORY_SORT_CLICK_COUNT = 2; CATEGORY_SORT_COMMENT_COUNT = 3; CATEGORY_SORT_LIKE_COUNT = 4; } enum UserType { ALL = 0; UP = 1; NORMAL_USER = 2; AUTHENTICATED_USER = 3; } enum UserSort { USER_SORT_DEFAULT = 0; USER_SORT_FANS_DESCEND = 1; USER_SORT_FANS_ASCEND = 2; USER_SORT_LEVEL_DESCEND = 3; USER_SORT_LEVEL_ASCEND = 4; } // 搜索目标类型, 番剧为7 int32 type = 1; // 关键词 string keyword = 2; // CategorySort category_sort = 3; // int64 category_id = 4; // UserType user_type = 5; // UserSort user_sort = 6; // bilibili.pagination.Pagination pagination = 7; // bilibili.app.archive.middleware.v1.PlayerArgs player_args = 8; } // message SearchByTypeResponse { // 追踪id string trackid = 1; // 当前页码 int32 pages = 2; // string exp_str = 3; // 搜索关键词 string keyword = 4; // 是否为推荐结果 int32 result_is_recommend = 5; // 搜索结果条目 repeated Item items = 6; // 分页信息 bilibili.pagination.PaginationReply pagination = 7; // map annotation = 8; } // message SearchCollectionCard { // string title = 1; // string cover = 2; // string author = 3; // repeated AvItem av_items = 4; // BottomButton bottom_button = 5; // string collection_icon = 6; // string show_card_desc1 = 7; // string show_card_desc2 = 8; } // message SearchComicCard { // string title = 1; // string cover = 2; // string name = 3; // string style = 4; // string comic_url = 5; // string badge = 6; } // message SearchComicInfo { // string uri = 1; // string param = 2; // SearchComicCard comic = 3; } // message SearchComicRequest { // string id_list = 1; } // message SearchComicResponse { // repeated SearchComicInfo items = 1; } // message SearchDynamicCard { // string title = 1; // string cover = 2; // int32 cover_count = 3; // repeated string covers = 4; // Upper upper = 5; // Stat stat = 6; // repeated DyTopic dy_topic = 7; } // message SearchGameCard { // string title = 1; // string cover = 2; // string reserve = 3; // float rating = 4; // string tags = 5; // string notice_name = 6; // string notice_content = 7; // string gift_content = 8; // string gift_url = 9; // int32 reserve_status = 10; // RankInfo rank_info = 11; // string special_bg_color = 12; // CloudGameParams cloud_game_params = 13; // bool show_cloud_game_entry = 14; } // message SearchInlineData { // string uri = 1; // string title = 2; // PlayerArgs player_args = 3; // int32 can_play = 4; // Args args = 5; // string card_goto = 6; // string card_type = 7; // string cover = 8; // int32 cover_left_icon1 = 9; // int32 cover_left_icon2 = 10; // string cover_left_text1 = 11; // string cover_left_text2 = 12; // UpArgs up_args = 13; // string extra_uri = 14; // bool is_fav = 15; // bool is_coin = 16; // string goto = 17; // Share share = 18; // ThreePoint2 three_point = 19; // repeated ThreePointV2 three_point_v2 = 20; // SharePlane share_plane = 21; // InlineThreePointPanel three_point_meta = 22; // Avatar avatar = 23; // string cover_right_text = 24; // string desc = 25; // InlineProgressBar inline_progress_bar = 26; // SearchLikeButtonItem like_button = 27; // int32 official_icon = 28; // int32 official_icon_v2 = 29; // string param = 30; // TrafficConfig traffic_config = 31; // bool is_atten = 32; // GotoIcon goto_icon = 33; // bool disable_danmaku = 34; // bool hide_danmaku_switch = 35; // ReasonStyle badge_style = 36; // PlayerWidget player_widget = 37; // ReasonStyle cover_badge_style = 38; // RightTopLiveBadge right_top_live_badge = 39; } // message SearchLikeButtonItem { // int64 aid = 1; // int64 count = 2; // int32 selected = 3; // bool show_count = 4; // LikeResource like_resource = 5; // LikeResource like_night_resource = 6; // LikeResource dislike_resource = 7; // LikeResource dislike_night_resource = 8; } // message SearchLiveCard { // string title = 1; // string cover = 2; // RcmdReason rcmd_reason = 3; // string name = 4; // int32 online = 5; // string badge = 6; // string live_link = 7; // string card_left_text = 8; // int32 card_left_icon = 9; // string show_card_desc2 = 10; // RightTopLiveBadge right_top_live_badge = 11; } // message SearchLiveInlineCard { // string title = 1; // string cover = 2; // int64 mid = 3; // ReasonStyle rcmd_reason_style = 4; // int64 roomid = 5; // string live_link = 6; // SearchInlineData live_room_inline = 7; // string inline_type = 8; } // message SearchNewChannelCard { // string title = 1; // string cover = 2; // int64 id = 3; // string type_icon = 4; // ChannelLabel channel_label1 = 5; // ChannelLabel channel_label2 = 6; // ChannelLabel channel_button = 7; // string design_type = 8; // repeated ChannelMixedItem items = 9; } // message SearchNoResultSuggestWordCard { // string title = 1; // string cover = 2; // int32 sug_key_word_type = 3; } // message SearchOgvCard { // string title = 1; // string sub_title1 = 2; // string sub_title2 = 3; // string cover = 4; // string bg_cover = 5; // string special_bg_color = 6; // string cover_uri = 7; } // message SearchOgvChannelCard { // string title = 1; // string cover = 2; // int64 media_id = 3; // string styles = 4; // string area = 5; // string staff = 6; // string badge = 7; // WatchButton watch_button = 8; // double rating = 9; // string desc = 10; // repeated ReasonStyle badges_v2 = 11; // string styles_v2 = 12; } // message SearchOgvInlineCard { // string title = 1; // string cover = 2; // string author = 3; // int32 danmaku = 4; // string desc = 5; // string face = 6; // string inline_type = 7; // int64 mid = 8; // int64 play = 9; // SearchInlineData ogv_inline = 10; // OgvClipInfo ogv_clip_info = 11; // WatchButton watch_button = 12; // string score = 13; // int32 ogv_inline_exp = 14; // repeated ReasonStyle badges_v2 = 15; } // message SearchOgvRecommendCard { // string title = 1; // string cover = 2; // repeated OgvRecommendWord items = 3; // string special_bg_color = 4; } // message SearchOgvRelationCard { // string title = 1; // string cover = 2; // string special_bg_color = 3; // string more_text = 4; // string more_url = 5; // repeated DetailsRelationItem items = 6; // int32 is_new_style = 7; // OgvCardUI ogv_card_ui = 8; } // message SearchOlympicGameCard { // string title = 1; // string cover = 2; // SportsMatchItem sports_match_item = 3; // MatchItem match_top = 4; // string bg_cover = 5; // repeated ExtraLink extra_link = 6; // string inline_type = 7; // SearchInlineData ugc_inline = 8; // SearchInlineData live_room_inline = 9; // MatchItem match_bottom = 10; } // message SearchOlympicWikiCard { // string title = 1; // string cover = 2; // CardBusinessBadge card_business_badge = 3; // NavigationButton read_more = 4; // string inline_type = 5; // SearchInlineData ugc_inline = 6; // SearchInlineData live_room_inline = 7; // PediaCover pedia_cover = 8; // repeated Navigation navigation = 9; } // message SearchPediaCard { // string title = 1; // string cover = 2; // repeated Navigation navigation = 3; // NavigationButton read_more = 4; // int32 navigation_module_count = 5; // PediaCover pedia_cover = 6; // CardBusinessBadge card_business_badge = 7; } // message SearchPurchaseCard { // string title = 1; // string cover = 2; // string badge = 3; // string venue = 4; // int32 price = 5; // string price_complete = 6; // int32 price_type = 7; // int32 required_number = 8; // string city = 9; // string show_time = 10; // int64 id = 11; // string shop_name = 12; } // message SearchRecommendTipCard { // string title = 1; // string cover = 2; } // message SearchRecommendWordCard { // string title = 1; // string cover = 2; // repeated RecommendWord list = 3; } // message SearchSpecialCard { // string title = 1; // string cover = 2; // repeated ReasonStyle new_rec_tags = 3; // CardBusinessBadge card_business_badge = 4; // string badge = 5; // string desc = 6; // repeated ReasonStyle new_rec_tags_v2 = 7; } // message SearchSpecialGuideCard { // string title = 1; // string cover = 2; // string phone = 3; // string desc = 4; } // message SearchSportCard { // string title = 1; // string cover = 2; // string bg_cover = 3; // MatchItem match_top = 4; // MatchItem match_bottom = 5; // repeated ExtraLink extra_link = 6; // repeated MatchInfoObj items = 7; // int64 id = 8; } // message SearchSportInlineCard { // string title = 1; // string cover = 2; // string bg_cover = 3; // MatchItem match_top = 4; // MatchItem match_bottom = 5; // repeated ExtraLink extra_link = 6; // repeated MatchInfoObj items = 7; // int64 id = 8; // SearchInlineData esports_inline = 9; // string inline_type = 10; } // message SearchTipsCard { // string title = 1; // string cover = 2; // string sub_title = 4; // string cover_night = 134; } // message SearchTopGameCard { // string title = 1; // string cover = 2; // int32 array = 3; // string background_image = 4; // int32 button_type = 5; // string game_icon = 6; // int64 game_base_id = 7; // int32 game_status = 8; // string inline_type = 9; // TopGameUI top_game_ui = 10; // string notice_content = 11; // string notice_name = 12; // float rating = 13; // string score = 14; // repeated TabInfo tab_info = 15; // string tags = 16; // SearchInlineData ugc_inline = 17; // string video_cover_image = 18; // SearchInlineData inline_live = 19; } // message SearchUgcInlineCard { // string title = 1; // string cover = 2; // string author = 3; // int32 danmaku = 4; // string desc = 5; // string inline_type = 6; // int64 mid = 7; // int64 play = 8; // SearchInlineData ugc_inline = 9; // FullTextResult full_text = 10; } // message SearchUpperCard { // string title = 1; // string cover = 2; // string sign = 3; // int32 fans = 4; // int32 archives = 5; // int32 live_status = 6; // int32 roomid = 7; // OfficialVerify official_verify = 8; // int32 face_nft_new = 9; // NftFaceIcon nft_face_icon = 10; // repeated AvItem av_items = 11; // bool is_up = 12; // int32 attentions = 13; // int32 level = 14; // int32 is_senior_member = 15; // VipInfo vip = 16; // Relation relation = 17; // string live_link = 18; // Notice notice = 19; } // message SearchVideoCard { // string title = 1; // string cover = 2; // RcmdReason rcmd_reason = 3; // repeated ReasonStyle new_rec_tags = 4; // repeated ThreePoint three_point = 5; // Share share = 6; // CardBusinessBadge card_business_badge = 7; // int64 play = 8; // int32 danmaku = 9; // string author = 10; // string desc = 11; // string duration = 12; // repeated ReasonStyle badges = 13; // int64 mid = 14; // string show_card_desc1 = 15; // string show_card_desc2 = 16; // FullTextResult full_text = 17; // repeated ReasonStyle new_rec_tags_v2 = 18; // repeated ReasonStyle badges_v2 = 19; // //Feedback feedback = 20; // //TimeLine time_line = 21; // string face = 22; // int32 ptime = 23; // string view_content = 24; // int32 icon_type = 25; // //FoldingTimeLine folding_time_line = 26; // //LabelStyle charging_label = 27; // //CardLayout card_layout = 28; // string author_prefix = 29; // repeated string highlight_tags = 30; // ReasonStyle cover_badge = 31; // //ShortOGVInfo short_ogv_info = 32; // int64 translation_status = 33; // string translated_title = 34; // repeated ReasonStyle quality_tags = 35; } // message Share { // string type = 1; // Video video = 2; } // message ShareButtonItem { // int32 type = 1; // repeated ButtonMeta button_metas = 2; } // message SharePlane { // string title = 1; // string share_subtitle = 2; // string desc = 3; // string cover = 4; // int64 aid = 5; // string bvid = 6; // ShareTo share_to = 7; // string author = 8; // int64 author_id = 9; // string short_link = 10; // string play_number = 11; // int64 room_id = 12; // int32 ep_id = 13; // string area_name = 14; // string author_face = 15; // int32 season_id = 16; // string share_from = 17; // string season_title = 18; // string from = 19; } // message ShareTo { // bool dynamic = 1; // bool im = 2; // bool copy = 3; // bool more = 4; // bool wechat = 5; // bool weibo = 6; // bool wechat_monment = 7; // bool qq = 8; // bool qzone = 9; // bool facebook = 10; // bool line = 11; // bool messenger = 12; // bool whats_app = 13; // bool twitter = 14; } // enum Sort { SORT_DEFAULT = 0; // SORT_VIEW_COUNT = 1; // SORT_PUBLISH_TIME = 2; // SORT_DANMAKU_COUNT = 3; // } // message Space { // int32 show = 1; // string text_color = 2; // string text_color_night = 3; // string text = 4; // string space_url = 5; } // message SportsMatchItem { // int64 match_id = 1; // int64 season_id = 2; // string match_name = 3; // string img = 4; // string begin_time_desc = 5; // string match_status_desc = 6; // string sub_content = 7; // string sub_extra_icon = 8; } // message Stat { // int64 play = 1; // int32 like = 2; // int32 reply = 3; } // message TabInfo { // string tab_name = 1; // string tab_url = 2; // int32 sort = 3; } // message TextButton { // string text = 1; // string uri = 2; } // message TextLabel { // string text = 1; // string uri = 2; } // message Texts { // string booking_text = 1; // string unbooking_text = 2; } // message ThreePoint { // string type = 1; // string icon = 2; // string title = 3; } // message ThreePoint2 { // repeated DislikeReason dislike_reasons = 1; // repeated DislikeReason feedbacks = 2; // int32 watch_later = 3; } // message ThreePointV2 { // string title = 1; // string subtitle = 2; // repeated DislikeReason reasons = 3; // string type = 4; // int64 id = 5; } // message ThreePointV3 { // string title = 1; // string selected_title = 2; // string subtitle = 3; // repeated DislikeReason reasons = 4; // string type = 5; // int64 id = 6; // int32 selected = 7; // string icon = 8; // string selected_icon = 9; // string url = 10; // int32 default_id = 11; } // message ThreePointV4 { // SharePlane share_plane = 1; // WatchLater watch_later = 2; } // message TopGameUI { // string background_image = 1; // string cover_default_color = 2; // string gaussian_blur_value = 3; // string mask_color_value = 4; // string mask_opacity = 5; // string module_color = 6; } // message TrafficConfig { // string title = 1; // repeated TrafficConfigOption options = 2; // int64 default_option_id = 3; } // message TrafficConfigOption { // int32 id = 1; // string text = 2; } // message UpArgs { // int64 up_id = 1; // string up_name = 2; // string up_face = 3; // int32 selected = 4; } // message Upper { // int64 mid = 1; // string title = 2; // string cover = 3; // string ptime_text = 4; } // enum UserSort { USER_SORT_DEFAULT = 0; // USER_SORT_FANS_DESCEND = 1; // USER_SORT_FANS_ASCEND = 2; // USER_SORT_LEVEL_DESCEND = 3; // USER_SORT_LEVEL_ASCEND = 4; // } // enum UserType { ALL = 0; // UP = 1; // NORMAL_USER = 2; // AUTHENTICATED_USER = 3; // } // message Video { // string bvid = 1; // int64 cid = 2; // string share_subtitle = 3; // bool is_hot_label = 4; // int32 page = 5; // int32 page_count = 6; // string short_link = 7; } // message VipInfo { // int32 type = 1; // int32 status = 2; // int64 due_date = 3; // int32 vip_pay_type = 4; // int32 theme_type = 5; // VipLabel label = 6; // int32 avatar_subscript = 7; // string nickname_color = 8; // int64 role = 9; // string avatar_subscript_url = 10; // int32 tv_vip_status = 11; // int32 tv_vip_pay_type = 12; } // message VipLabel { // string path = 1; // string text = 2; // string label_theme = 3; // string text_color = 4; // int32 bg_style = 5; // string bg_color = 6; // string border_color = 7; // bool use_img_label = 8; // string img_label_uri_hans = 9; // string img_label_uri_hant = 10; // string img_label_uri_hans_static = 11; // string img_label_uri_hant_static = 12; } // message WatchButton { // string title = 1; // string link = 2; } // message WatchedShow { // bool switch = 1; // int64 num = 2; // string text_small = 3; // string text_large = 4; // string icon = 5; // string icon_location = 6; // string icon_web = 7; } // message WatchLater { // int64 aid = 1; // string bvid = 2; } ================================================ FILE: bili-api/grpc/proto/bilibili/polymer/community/govern/v1/govern.proto ================================================ syntax = "proto3"; package bilibili.polymer.app.govern.v1; option java_multiple_files = true; // 反骚扰 service AntiHarassmentService { // rpc StoreAntiHarassmentSettings(StoreAntiHarassmentSettingsReq) returns (StoreAntiHarassmentSettingsRsp); // rpc LoadAntiHarassmentSettings(LoadAntiHarassmentSettingsReq) returns (LoadAntiHarassmentSettingsRsp); } // message AntiHarassmentInfo { // int32 limit = 1; // int32 follow_time_limit_second = 2; // int64 expire_time = 3; } // enum AntiHarassmentLimit { DefaultLimit = 0; // FollowLimit = 1; // ReFollowLimit = 2; // TwoWayFollow = 3; // AllLimit = 4; // } // message AntiHarassmentSetting { // int64 mid = 1; // bool auto_limit = 2; // AntiHarassmentInfo im = 3; // AntiHarassmentInfo reply = 4; // AntiHarassmentInfo dm = 5; // AntiHarassmentInfo reply_me = 6; // AntiHarassmentInfo like_me = 7; // AntiHarassmentInfo at_me = 8; // int64 auto_limit_expire_time = 9; } // enum BizType { InvalidBizType = 0; // Im = 1; // Dm = 2; // Reply = 3; // ReplyMe = 4; // LikeMe = 5; // AtMe = 6; // } // message LoadAntiHarassmentSettingsReq { // int32 biz_type = 1; // int64 recv_mid = 2; // int64 send_mid = 3; } // message LoadAntiHarassmentSettingsRsp { // bool anti_harassment_ret = 1; // AntiHarassmentSetting anti_harassment_setting = 2; // int32 show_window = 3; } // message StoreAntiHarassmentSettingsReq { // int32 biz_type = 1; // int64 mid = 2; // AntiHarassmentSetting anti_harassment_setting = 3; } // message StoreAntiHarassmentSettingsRsp {} ================================================ FILE: bili-api/grpc/proto/bilibili/polymer/contract/v1/contract.proto ================================================ syntax = "proto3"; package bilibili.polymer.contract.v1; option java_multiple_files = true; import "google/protobuf/empty.proto"; // 契约 service Contract { // rpc AddContract(AddContractReq) returns (google.protobuf.Empty); // rpc AddContractV2(AddContractReq) returns (AddContractReply); // rpc ContractConfig(ContractConfigReq) returns (ContractConfigReply); } // message AddContractReply { // bool allow_message = 1; // bool allow_reply = 2; // string input_text = 3; // string input_title = 4; } // message AddContractReq { // CommonReq common = 1; // int64 mid = 2; // int64 up_mid = 3; // int64 aid = 4; // int32 source = 5; } // message CommonReq { // string platform = 1; // int32 build = 2; // string buvid = 3; // string mobi_app = 4; // string device = 5; // string ip = 6; // string spmid = 7; } // message ContractCard { // string title = 1; // string sub_title = 2; } // message ContractConfigReply { // int32 is_follow_display = 1; // int32 is_triple_display = 2; // ContractCard contract_card = 3; } // message ContractConfigReq { // CommonReq common = 1; // int64 mid = 2; // int64 up_mid = 3; // int64 aid = 4; // int32 source = 5; } ================================================ FILE: bili-api/grpc/proto/bilibili/polymer/demo/demo.proto ================================================ syntax = "proto3"; package bilibili.polymer.demo; option java_multiple_files = true; // message HelloWorldReq { // string content = 1; } // message HelloWorldResp { // string data = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/polymer/list/v1/list.proto ================================================ syntax = "proto3"; package bilibili.polymer.list.v1; option java_multiple_files = true; // service List { // rpc FavoriteTab(FavoriteTabReq) returns (FavoriteTabReply); // rpc CheckAccount(CheckAccountReq) returns (CheckAccountReply); } // message CheckAccountReply { // bool is_new = 1; } // message CheckAccountReq { // int64 uid = 1; // string periods = 2; } // message FavoriteTabItem { // string name = 1; // string uri = 2; // string type = 3; } // message FavoriteTabReply { // repeated FavoriteTabItem items = 1; } // message FavoriteTabReq {} ================================================ FILE: bili-api/grpc/proto/bilibili/relation/interfaces/api.proto ================================================ syntax = "proto3"; package bilibili.relation.interface.v1; option java_multiple_files = true; option java_package = "bilibili.relation.interface1.v1"; service RelationInterface { // 评论区 At 用户列表 (无需登录鉴权) rpc AtSearch (AtSearchReq) returns (AtSearchReply); } message AtSearchReq { // 可以为 1 , 但是不能为 0 或空 不知道有啥用 int64 mid = 1; // 用户名搜索关键词 string keyword = 2; } message AtSearchReply { // 搜索结果分组 repeated AtGroup items = 1; } message AtGroup { // 分组类型 2: 我的关注 4:其他 ,其他自测 int32 group_type = 1; // 分组名称 string group_name = 2; // 用户列表 repeated AtItem items = 3; } message AtItem { int64 mid = 1; string name = 2; string face = 3; int32 fans = 4; int32 official_verify_type = 5; } ================================================ FILE: bili-api/grpc/proto/bilibili/render/render.proto ================================================ syntax = "proto3"; package bilibili.render; option java_multiple_files = true; import "google/protobuf/any.proto"; // message Render { // int64 code = 1; // string message = 2; // string ttl = 3; // google.protobuf.Any data = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/rpc/status.proto ================================================ syntax = "proto3"; package bilibili.rpc; option java_multiple_files = true; import "google/protobuf/any.proto"; // 响应gRPC Status // 当status code是[UNKNOWN = 2]时,details为业务详细的错误信息,进行proto any转换成业务码结构体 message Status { // 业务错误码 int32 code = 1; // 业务错误信息 string message = 2; // 扩展信息嵌套(相当于该messasge的套娃) repeated google.protobuf.Any details = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/tv/interfaces/dm/v1/dm.proto ================================================ syntax = "proto3"; package bilibili.tv.interfaces.dm.v1; option java_multiple_files = true; option java_package = "bilibili.tv.interfaces.dm.v1"; // message Chronos { // string md5 = 1; // string file = 2; // string sign = 3; } // 互动弹幕条目信息 message CommandDm { // 弹幕id int64 id = 1; // 对象视频cid int64 oid = 2; // 发送者mid string mid = 3; // 互动弹幕指令 string command = 4; // 互动弹幕正文 string content = 5; // 出现时间 int32 progress = 6; // 创建时间 string ctime = 7; // 发布时间 string mtime = 8; // 扩展json数据 string extra = 9; // 弹幕id str类型 string idStr = 10; // int64 display = 11; } // ott互动弹幕条目信息 message CommandDmOtt { // 弹幕id int64 id = 1; // 对象视频cid int64 oid = 2; // 发送者mid int64 mid = 3; // int32 type = 4; // 互动弹幕指令 string command = 5; // 互动弹幕正文 string content = 6; // int32 state = 7; // 出现时间 int32 progress = 8; // 创建时间 string ctime = 9; // 发布时间 string mtime = 10; // 扩展json数据 string extra = 11; // 弹幕id str类型 string id_str = 12; } // message CommandDmsOttReply { // repeated CommandDmOtt command_dms_ott = 1; } // message CommandDmsOttReq { // int64 aid = 1; // int64 cid = 2; // int64 mid = 3; } // 弹幕ai云屏蔽列表 message DanmakuAIFlag { // 弹幕ai云屏蔽条目 repeated DanmakuFlag dm_flags = 1; } // 弹幕条目 message DanmakuElem { // 弹幕dmid int64 id = 1; // 弹幕出现位置(单位ms) int32 progress = 2; // 弹幕类型 int32 mode = 3; // 弹幕字号 int32 fontsize = 4; // 弹幕颜色 uint32 color = 5; // 发送着mid hash string midHash = 6; // 弹幕正文 string content = 7; // 发送时间 int64 ctime = 8; // 权重 区间:[1,10] int32 weight = 9; // 动作 string action = 10; // 弹幕池 int32 pool = 11; // 弹幕dmid str string idStr = 12; // 弹幕属性位(bin求AND) // bit0:保护 bit1:直播 bit2:高赞 int32 attr = 13; } // 弹幕ai云屏蔽条目 message DanmakuFlag { // 弹幕dmid int64 dmid = 1; // 评分 uint32 flag = 2; } // 云屏蔽配置信息 message DanmakuFlagConfig { // 云屏蔽等级 int32 rec_flag = 1; // 云屏蔽文案 string rec_text = 2; // 云屏蔽开关 int32 rec_switch = 3; } // 弹幕默认配置 message DanmuDefaultPlayerConfig { bool player_danmaku_use_default_config = 1; // 是否使用推荐弹幕设置 bool player_danmaku_ai_recommended_switch = 4; // 是否开启智能云屏蔽 int32 player_danmaku_ai_recommended_level = 5; // 智能云屏蔽等级 bool player_danmaku_blocktop = 6; // 是否屏蔽顶端弹幕 bool player_danmaku_blockscroll = 7; // 是否屏蔽滚动弹幕 bool player_danmaku_blockbottom = 8; // 是否屏蔽底端弹幕 bool player_danmaku_blockcolorful = 9; // 是否屏蔽彩色弹幕 bool player_danmaku_blockrepeat = 10; // 是否屏蔽重复弹幕 bool player_danmaku_blockspecial = 11; // 是否屏蔽高级弹幕 float player_danmaku_opacity = 12; // 弹幕不透明度 float player_danmaku_scalingfactor = 13; // 弹幕缩放比例 float player_danmaku_domain = 14; // 弹幕显示区域 int32 player_danmaku_speed = 15; // 弹幕速度 bool inline_player_danmaku_switch = 16; // 是否开启弹幕 int32 player_danmaku_senior_mode_switch = 17; // } // 弹幕配置 message DanmuPlayerConfig { bool player_danmaku_switch = 1; // 是否开启弹幕 bool player_danmaku_switch_save = 2; // 是否记录弹幕开关设置 bool player_danmaku_use_default_config = 3; // 是否使用推荐弹幕设置 bool player_danmaku_ai_recommended_switch = 4; // 是否开启智能云屏蔽 int32 player_danmaku_ai_recommended_level = 5; // 智能云屏蔽等级 bool player_danmaku_blocktop = 6; // 是否屏蔽顶端弹幕 bool player_danmaku_blockscroll = 7; // 是否屏蔽滚动弹幕 bool player_danmaku_blockbottom = 8; // 是否屏蔽底端弹幕 bool player_danmaku_blockcolorful = 9; // 是否屏蔽彩色弹幕 bool player_danmaku_blockrepeat = 10; // 是否屏蔽重复弹幕 bool player_danmaku_blockspecial = 11; // 是否屏蔽高级弹幕 float player_danmaku_opacity = 12; // 弹幕不透明度 float player_danmaku_scalingfactor = 13; // 弹幕缩放比例 float player_danmaku_domain = 14; // 弹幕显示区域 int32 player_danmaku_speed = 15; // 弹幕速度 bool player_danmaku_enableblocklist = 16; // 是否开启屏蔽列表 bool inline_player_danmaku_switch = 17; // 是否开启弹幕 int32 inline_player_danmaku_config = 18; // int32 player_danmaku_ios_switch_save = 19; // } // 弹幕显示区域自动配置 message DanmuPlayerDynamicConfig { // 时间 int32 progress = 1; // 弹幕显示区域 float player_danmaku_domain = 14; } // 弹幕配置信息 message DanmuPlayerViewConfig { // 弹幕默认配置 DanmuDefaultPlayerConfig danmuku_default_player_config = 1; // 弹幕用户配置 DanmuPlayerConfig danmuku_player_config = 2; // 弹幕显示区域自动配置列表 repeated DanmuPlayerDynamicConfig danmuku_player_dynamic_config = 3; } // 获取弹幕-响应 message DmSegMobileReply { // 弹幕列表 repeated DanmakuElem elems = 1; // 是否已关闭弹幕 // 0:未关闭 1:已关闭 int32 state = 2; // 弹幕云屏蔽ai评分值 DanmakuAIFlag ai_flag = 3; } // 获取弹幕-请求 message DmSegMobileReq { // 稿件avid/漫画epid int64 pid = 1; // 视频cid/漫画cid int64 oid = 2; // 弹幕类型 // 1:视频 2:漫画 int32 type = 3; // 分段(6min) int64 segment_index = 4; // 是否青少年模式 int32 teenagers_mode = 5; // int64 from = 6; } // 客户端弹幕元数据-响应 message DmViewReply { // 是否已关闭弹幕 // 0:未关闭 1:已关闭 bool closed = 1; // 智能防挡弹幕蒙版信息 VideoMask mask = 2; // 视频字幕 VideoSubtitle subtitle = 3; // 高级弹幕专包url(bfs) repeated string special_dms = 4; // 云屏蔽配置信息 DanmakuFlagConfig ai_flag = 5; // 弹幕配置信息 DanmuPlayerViewConfig player_config = 6; // 弹幕发送框样式 int32 send_box_style = 7; // 是否允许 bool allow = 8; // check box 是否展示 string check_box = 9; // check box 展示文本 string check_box_show_msg = 10; // 展示文案 string text_placeholder = 11; // 弹幕输入框文案 string input_placeholder = 12; // bool command_close = 13; } // 客户端弹幕元数据-请求 message DmViewReq { // 稿件avid/漫画epid int64 pid = 1; // 视频cid/漫画cid int64 oid = 2; // 弹幕类型 // 1:视频 2:漫画 int32 type = 3; // 页面spm string spmid = 4; // 是否冷启 int32 is_hard_boot = 5; // int64 from = 6; } // 单个字幕信息 message SubtitleItem { // 字幕id int64 id = 1; // 字幕id str string id_str = 2; // 字幕语言代码 string lan = 3; // 字幕语言 string lan_doc = 4; // 字幕文件url string subtitle_url = 5; // 字幕作者信息 UserInfo author = 6; } // message TvViewProgressReply { // VideoGuide video_guide = 1; // Chronos chronos = 2; } // message TvViewProgressReq { // int64 aid = 1; // int64 cid = 2; // int64 up_mid = 3; // string engine_version = 4; // string message_protocol = 5; // string service_key = 6; // int64 sid = 7; // int64 pid = 8; // int64 from = 9; // string guest_access_key = 10; // int64 epid = 11; } // 字幕作者信息 message UserInfo { // 用户mid int64 mid = 1; // 用户昵称 string name = 2; // 用户性别 string sex = 3; // 用户头像url string face = 4; // 用户签名 string sign = 5; // 用户等级 int32 rank = 6; } // message VideoGuide { // repeated CommandDm command_dms = 2; } // 智能防挡弹幕蒙版信息 message VideoMask { // 视频cid int64 cid = 1; // 平台 // 0:web端 1:客户端 int32 plat = 2; // 帧率 int32 fps = 3; // 间隔时间 int64 time = 4; // 蒙版url string mask_url = 5; } // 视频字幕信息 message VideoSubtitle { // 视频原语言代码 string lan = 1; // 视频原语言 string lanDoc = 2; // 视频字幕列表 repeated SubtitleItem subtitles = 3; } ================================================ FILE: bili-api/grpc/proto/bilibili/vega/deneb/v1/deneb.proto ================================================ syntax = "proto3"; package bilibili.vega.deneb.v1; option java_multiple_files = true; import "google/protobuf/any.proto"; // service VegaDenebRPC { // rpc MessagePulls (MessagePullsReq) returns (MessagePullsReply); } // message MessagePullsReply { // repeated google.protobuf.Any data = 1; // int32 pn = 2; // int32 ps = 3; // int64 count = 4; // bool has_next = 5; } // message MessagePullsReq { // int64 start_seq_id = 1; // int64 end_seq_id = 2; // int32 pn = 3; // int32 ps = 4; } ================================================ FILE: bili-api/grpc/proto/bilibili/web/interfaces/v1/interfaces.proto ================================================ syntax = "proto3"; package bilibili.web.interfaces.v1; option java_multiple_files = true; option java_package = "bilibili.web.interfaces.v1"; // 用户信息 message AccInfo { // 用户UID int64 mid = 1; // 用户昵称 string name = 2; // string sex = 3; // string face = 4; // string sign = 5; } // message AccountCard { // string mid = 1; // string name = 2; // string sex = 3; // string rank = 4; // string face = 5; // int32 spacesta = 6; // string sign = 7; // CardLevelInfo level_info = 8; // PendantInfo pendant = 9; // NameplateInfo nameplate = 10; // OfficialInfo official = 11; // OfficialVerify official_verify = 12; // CardVip vip = 13; // int64 fans = 14; // int64 friend = 15; // int64 attention = 16; } // message ActivityArchiveReply { // Arc arc = 1; // string bvid = 2; // repeated Page pages = 3; // ReqUser req_user = 4; // repeated Staff staff = 5; // OperationRelate right_relate = 6; // OperationRelate bottom_relate = 7; } // message ActivityArchiveReq { // int64 aid = 1; // string bvid = 2; // string activity_key = 3; } // message ActivityEpisode { // int64 id = 1; // int64 aid = 2; // string bvid = 3; // int64 cid = 4; // string title = 5; // string cover = 6; // Author author = 7; // Rights rights = 8; } // message ActivityGame { // repeated ActivityGameIframe iframes = 1; // string disclaimer = 2; // string disclaimer_url = 3; } // message ActivityGameIframe { // string url = 1; // int64 height = 2; } // message ActivityLive { // int64 room_id = 1; // int64 now_time = 2; // int64 start_time = 3; // int64 end_time = 4; // string hover_pic = 5; // string hover_jump_url = 6; // int64 break_cycle = 7; // repeated LiveTimeline timeline = 8; // OperationRelate operation_relate = 9; // int64 reply_type = 10; // int64 reply_id = 11; // string hover_pic_close = 12; // string gift_disclaimer = 13; } // message ActivityLiveTimeInfoReply { // int64 now_time = 1; // int64 start_time = 2; // int64 end_time = 3; // repeated LiveTimeline timeline = 4; } // message ActivityLiveTimeInfoReq { // string activity_key = 1; } // message ActivitySeasonReply { // ActivitySeasonStatus status = 1; // string title = 2; // ActivityLive live = 3; // ActivitySubscribe subscribe = 4; // ActivityGame game = 5; // ActivityView view = 6; // ActivityTheme theme = 7; } // message ActivitySeasonReq { // int64 aid = 1; // string bvid = 2; // string activity_key = 3; } // message ActivitySeasonSection { // int64 id = 1; // string title = 2; // int64 type = 3; // repeated ActivityEpisode episodes = 4; } // enum ActivitySeasonStatus { StatusNone = 0; // StatusLive = 1; // StatusView = 2; // } // message ActivitySubscribe { // bool status = 1; // string title = 2; // string button_title = 3; // string button_selected_title = 4; // int64 season_stat_view = 5; // int64 season_stat_danmaku = 6; // OrderType order_type = 7; oneof param { // ReserveActivityParam reserve_activity_param = 8; // FavSeasonParam fav_season_param = 9; // JumpURLParam jump_URL_param = 10; } } // message ActivityTheme { // string base_color = 1; // string loading_bg_color = 2; // string operated_bg_color = 3; // string default_element_color = 4; // string hover_element_color = 5; // string selected_element_color = 6; // string base_font_color = 7; // string info_font_color = 8; // string mask_bg_color = 9; // string page_bg_color = 10; // string center_logo_img = 11; // string page_bg_img = 12; // string decorations2233_img = 13; // string main_banner_bg_img = 14; // string main_banner_title_img = 15; // string like_animation_img = 16; // string combo_like_img = 17; // string combo_coin_img = 18; // string combo_fav_img = 19; // string arrow_btn_img = 20; // string share_icon_bg_img = 21; // string live_list_location_img = 22; // string live_list_location_img_active = 23; // string player_loading_img = 24; // string share_img = 25; // map kv_color = 26; } // message ActivityView { // Arc arc = 1; // string bvid = 2; // repeated Page pages = 3; // repeated Staff staff = 4; // ReqUser req_user = 5; // OperationRelate right_relate = 6; // OperationRelate bottom_relate = 7; // repeated ActivitySeasonSection sections = 8; } // message Arc { // int64 aid = 1; // int64 videos = 2; // int32 type_id = 3; // string type_name = 4; // int32 copyright = 5; // string pic = 6; // string title = 7; // int64 pubdate = 8; // int64 ctime = 9; // string desc = 10; // int32 state = 11; // int32 access = 12; // int32 attribute = 13; // string tag = 14; // repeated string tags = 15; // int64 duration = 16; // int64 mission_id = 17; // int64 order_id = 18; // string redirect_url = 19; // int64 forward = 20; // Rights rights = 21; // Author author = 22; // Stat stat = 23; // string report_result = 24; // string dynamic = 25; // int64 first_cid = 26; // Dimension dimension = 27; // repeated StaffInfo staff_info = 28; // int64 season_id = 29; // repeated DescV2 desc_v2 = 30; // bool is_chargeable_season = 31; // bool is_blooper = 32; } // message Author { // int64 mid = 1; // string name = 2; // string face = 3; } // message Card { // AccountCard card = 1; // Space space = 2; // bool following = 3; // int64 archive_count = 4; // int32 article_count = 5; // int64 follower = 6; } // message CardLevelInfo { // int32 cur = 1; // int32 min = 2; // int32 now_exp = 3; // int32 next_exp = 4; } // message CardVip { // int32 type = 1; // string due_remark = 2; // int32 access_status = 3; // int32 vip_status = 4; // string vip_status_warn = 5; // int32 theme_type = 6; } message ClickActivitySeasonReq { // OrderType order_type = 1; oneof param { // ReserveActivityParam reserve_activity_param = 2; // FavSeasonParam fav_season_param = 3; // JumpURLParam jump_URL_param = 4; } // string spmid = 5; // int64 action = 6; } // message DescV2 { // string raw_text = 1; // int64 type = 2; // int64 biz_id = 3; } // message Dimension { // int64 width = 1; // int64 height = 2; // int64 rotate = 3; } // message FavSeasonParam { // int64 season_id = 1; } // message HotReply { // ReplyPage page = 1; // repeated Reply replies = 2; } // message JumpURLParam { // string jump_url = 1; } // message LiveTimeline { // string name = 1; // int64 start_time = 2; // int64 end_time = 3; // string cover = 4; // string subtitle = 5; // string h5_cover = 6; } // message NameplateInfo { // int32 nid = 1; // string name = 2; // string image = 3; // string image_small = 4; // string level = 5; // string condition = 6; } // message NoReply {} // message OfficialInfo { // int32 role = 1; // string title = 2; // string desc = 3; } // message OfficialVerify { // int32 type = 1; // string desc = 2; } // message OperationRelate { // string title = 1; // repeated RelateItem relate_item = 2; // repeated Relate ai_relate_item = 3; } // enum OrderType { TypeNone = 0; // TypeOrderActivity = 1; // TypeFavSeason = 2; // TypeClick = 3; // } // message Page { // int64 cid = 1; // int32 page = 2; // string from = 3; // string part = 4; // int64 duration = 5; // string vid = 6; // string desc = 7; // string weblink = 8; // Dimension dimension = 9; } // message PendantInfo { // int32 pid = 1; // string name = 2; // string image = 3; // int64 expire = 4; } // message ReasonStyle { // string text = 1; } // message Relate { // Arc arc = 1; // string bvid = 2; // int64 season_type = 3; } // message RelateItem { // string url = 1; // string cover = 2; } // message Relation { // int64 attribute = 1; // int64 special = 3; } // message Reply { // int64 rpid = 1; // int64 oid = 2; // int32 type = 3; // int64 mid = 4; // int64 root = 5; // int64 parent = 6; // int64 dialog = 7; // int32 count = 8; // int32 rcount = 9; // int32 floor = 10; // int32 state = 11; // int32 fans_grade = 12; // int32 attr = 13; // int64 ctime = 14; // string rpid_str = 15; // string root_str = 16; // string parent_str = 17; // string dialog_str = 18; // int32 like = 19; // int32 hate = 20; // int32 action = 21; // ReplyMember member = 22; // ReplyContent content = 23; // repeated Reply replies = 24; // int32 assist = 25; // ReplyFolder folder = 26; // ReplyUpAction up_action = 27; // ReplyLabel label = 28; // string raw_input = 29; // bool show_follow = 30; } // message ReplyContent { // int64 rp_id = 1; // string message = 2; // ReplyVote vote = 3; // repeated string topics = 5; // int32 ip = 6; // int32 plat = 7; // string device = 8; // string version = 9; // repeated ReplyMemberInfo members = 10; // map emote = 11; } // message ReplyEmote { // int64 id = 1; // int64 package_id = 2; // int64 state = 3; // int64 type = 4; // int64 attr = 5; // string text = 6; // string url = 7; // ReplyEmoteMeta meta = 8; // int64 ctime = 9; // int64 mtime = 10; } // message ReplyEmoteMeta { // ReplyEmoteMetaSize size = 1; } // enum ReplyEmoteMetaSize { EMOTE_META_SIZE_UNSPECIFIED = 0; // EMOTE_META_SIZE_SMALL = 1; // EMOTE_META_SIZE_BIG = 2; // } // message ReplyFansDetail { // int64 uid = 1; // int32 medal_id = 2; // string medal_name = 3; // int32 score = 4; // int32 level = 5; // int32 intimacy = 6; // int32 status = 7; // int32 received = 8; } // message ReplyFolder { // bool has_folded = 1; // bool is_folded = 2; // string rule = 3; } // message ReplyLabel { // int64 rpid = 1; // string content = 2; // string text_color = 3; // string text_color_night_mode = 4; // string bg_color = 5; // string bg_color_night_mode = 6; // string link = 7; // string position = 8; } // message ReplyLevelInfo { // int32 cur = 1; // int32 min = 2; // int32 now_exp = 3; // int32 next_exp = 4; } // message ReplyMember { // ReplyMemberInfo info = 1; // ReplyFansDetail fans_detail = 2; // int32 following = 3; } // message ReplyMemberInfo { // int32 role = 1; // string mid = 2; // string name = 3; // string sex = 4; // string sign = 5; // string avatar = 6; // string rank = 7; // string display_rank = 8; // ReplyLevelInfo level_info = 9; // PendantInfo pendant = 10; // NameplateInfo nameplate = 11; // OfficialVerify official_verify = 12; // ReplyVip vip = 13; } // message ReplyPage { // int64 acount = 1; // int64 count = 2; // int64 num = 3; // int64 size = 4; } // message ReplyUpAction { // bool like = 1; // bool reply = 2; } // message ReplyVip { // int32 type = 1; // int64 due_date = 2; // string due_remark = 3; // int32 access_status = 4; // int32 vip_status = 5; // string vip_status_warn = 6; // int32 theme_type = 7; // VipLabel label = 8; } // message ReplyVote { // int64 id = 1; // string title = 2; // int32 cnt = 3; // string desc = 4; // bool deleted = 5; } // message ReqUser { // bool favorite = 1; // bool like = 2; // bool dislike = 3; // int64 multiply = 4; } // message ReserveActivityParam { // int64 reserve_id = 1; // string from = 2; // string type = 3; // int64 oid = 4; } // message Rights { // int32 bp = 1; // int32 elec = 2; // int32 download = 3; // int32 movie = 4; // int32 pay = 5; // int32 hd5 = 6; // int32 no_reprint = 7; // int32 autoplay = 8; // int32 ugc_pay = 9; // int32 is_cooperation = 10; // int32 ugc_pay_preview = 11; // int32 arc_pay = 12; // int32 free_watch = 13; } // message SeasonEpisode { // int64 season_id = 1; // int64 section_id = 2; // int64 id = 3; // int64 aid = 4; // int64 cid = 5; // string title = 6; // int64 attribute = 7; // Arc arc = 8; // Page page = 9; // string bvid = 10; // ReasonStyle badge_style = 11; } // message SeasonSection { // int64 season_id = 1; // int64 id = 2; // string title = 3; // int64 type = 4; // repeated SeasonEpisode episodes = 5; } // message SeasonStat { // int64 season_id = 1; // int64 view = 2; // int32 danmaku = 3; // int32 reply = 4; // int32 fav = 5; // int32 coin = 6; // int32 share = 7; // int32 now_rank = 8; // int32 his_rank = 9; // int32 like = 10; } // message Space { // string s_img = 1; // string l_img = 2; } // message Staff { // int64 mid = 1; // string title = 2; // string name = 3; // string face = 4; // VipInfo vip = 5; // OfficialInfo official = 6; // int64 follower = 7; // int32 label_style = 8; // Relation relation = 9; } // message StaffInfo { // int64 mid = 1; // string title = 2; } // message Stat { // int64 aid = 1; // int64 view = 2; // int32 danmaku = 3; // int32 reply = 4; // int32 fav = 5; // int32 coin = 6; // int32 share = 7; // int32 now_rank = 8; // int32 his_rank = 9; // int32 like = 10; // int32 dislike = 11; // string evaluation = 12; // string argue_msg = 13; } // message Subtitle { // bool allow_submit = 1; // repeated SubtitleItem list = 2; } // message SubtitleItem { // int64 id = 1; // string lan = 2; // string lan_doc = 3; // bool is_lock = 4; // int64 author_mid = 5; // string subtitle_url = 6; // AccInfo author = 7; } // message Tag { // int64 id = 1; // string name = 2; // string cover = 3; // string head_cover = 4; // string content = 5; // string short_content = 6; // int32 type = 7; // int32 state = 8; // int64 ctime = 9; // TagCount tag_count = 10; // int32 is_atten = 11; // int64 likes = 12; // int64 hates = 13; // int32 attribute = 14; // int32 liked = 15; // int32 hated = 16; } // message TagCount { // int64 view = 1; // int64 use = 2; // int64 atten = 3; } // message UGCPayAsset { // int64 price = 1; // map platform_price = 2; } // message UGCSeason { // int64 id = 1; // string title = 2; // string cover = 3; // int64 mid = 4; // string intro = 5; // int32 sign_state = 6; // int64 attribute = 7; // repeated SeasonSection sections = 8; // SeasonStat stat = 9; // int64 ep_count = 10; // int64 season_type = 11; // bool is_pay_season = 12; } // message View { // Arc arc = 1; // bool no_cache = 2; // repeated Page pages = 3; // Subtitle subtitle = 4; // UGCPayAsset asset = 5; // ViewLabel label = 6; // repeated Staff staff = 7; // UGCSeason ugc_season = 8; // int64 stein_guide_cid = 9; } // message ViewDetailReply { // View view = 1; // Card card = 2; // repeated Tag tags = 3; // HotReply reply = 4; // repeated Arc related = 5; } // message ViewDetailReq { // int64 aid = 1; // string bvid = 2; } // message ViewLabel { // int64 type = 1; } // message VipInfo { // int32 type = 1; // int32 status = 2; // int32 vip_pay_type = 3; // int32 theme_type = 4; } // message VipLabel { // string path = 1; } ================================================ FILE: bili-api/grpc/proto/bilibili/web/space/v1/space.proto ================================================ syntax = "proto3"; package bilibili.web.space.v1; option java_multiple_files = true; // message NoReply {} // message OfficialReply { // int64 id = 1; // int64 uid = 2; // string name = 3; // string icon = 4; // string scheme = 5; // string rcmd = 6; // string ios_url = 7; // string android_url = 8; // string button = 9; // string deleted = 10; // int64 mtime = 11; } // message OfficialRequest { // int64 mid = 1; } // message PhotoMall { // int64 id = 1; // string name = 2; // string img = 3; // string night_img = 4; // int64 is_activated = 5; } // message PhotoMallListReply { // repeated PhotoMall list = 1; } // message PhotoMallListReq { // string mobiapp = 1; // int64 mid = 2; // string device = 3; } // message PrivacyReply { // map privacy = 1; } // message PrivacyRequest { // int64 mid = 1; } // message SetTopPhotoReq { // string mobiapp = 1; // int64 i_d = 2; // int64 mid = 3; // int32 type = 4; } // message SpaceSettingReply { // int64 channel = 1; // int64 fav_video = 2; // int64 coins_video = 3; // int64 likes_video = 4; // int64 bangumi = 5; // int64 played_game = 6; // int64 groups = 7; // int64 comic = 8; // int64 b_bq = 9; // int64 dress_up = 10; // int64 disable_following = 11; // int64 live_playback = 12; // int64 close_space_medal = 13; // int64 only_show_wearing = 14; // int64 disable_show_school = 15; // int64 disable_show_nft = 16; } // message SpaceSettingReq { // int64 mid = 1; } // message TopPhoto { // string img_url = 1; // string night_img_url = 2; // int64 sid = 3; } // message TopPhotoArc { // bool show = 1; // int64 aid = 2; // string pic = 3; } // message TopPhotoArcCancelReq { // int64 mid = 1; } // message TopPhotoReply { // TopPhoto top_photo = 1; // TopPhotoArc top_photo_arc = 2; } // message TopPhotoReq { // string mobiapp = 1; // int64 mid = 2; // int32 build = 3; // string device = 4; // int64 login_mid = 5; } // enum TopPhotoType { UNKNOWN = 0; // PIC = 1; // ARCHIVE = 2; // } // message UpActivityTabReq { // int64 mid = 1; // int32 state = 2; // int64 tab_cont = 3; // string tab_name = 4; } // message UpActivityTabResp { // bool success = 1; } // message UpRcmdBlackListReply {} // message UserTabReply { // int32 tab_type = 1; // int64 mid = 2; // string tab_name = 3; // int32 tab_order = 4; // int64 tab_cont = 5; // int32 is_default = 6; // string h5_link = 7; } // message UserTabReq { // int64 mid = 1; // int32 plat = 2; // int32 build = 3; } // message WhitelistAddReply { // bool add_ok = 1; } // message WhitelistAddReq { // int64 mid = 1; // int64 stime = 2; // int64 etime = 3; } // message WhitelistReply { // bool is_white = 1; } // message WhitelistReq { // int64 mid = 1; } // message WhitelistUpReply { // bool up_ok = 1; } // message WhitelistValidTimeReply { // bool is_white = 1; // int64 stime = 2; // int64 etime = 3; } ================================================ FILE: bili-api/grpc/proto/common/ErrorProto.proto ================================================ syntax = "proto3"; package common; option java_multiple_files = true; import "google/protobuf/any.proto"; // 响应gRPC Status // 当status code是[UNKNOWN = 2]时,details为业务详细的错误信息,进行proto any转换成业务码结构体 message ErrorProto { // 业务错误码 int32 code = 1; // 业务错误信息 string message = 2; // 扩展信息嵌套(相当于该messasge的套娃) repeated google.protobuf.Any details = 3; } ================================================ FILE: bili-api/grpc/proto/datacenter/hakase/protobuf/android_device_info.proto ================================================ syntax = "proto3"; package datacenter.hakase.protobuf; option java_multiple_files = true; message AndroidDeviceInfo { // ? string sdkver = 1; // 产品id // 粉 白 蓝 直播姬 HD 海外 OTT 漫画 TV野版 小视频 网易漫画 网易漫画 网易漫画HD 国际版 东南亚版 // 1 2 3 4 5 6 7 8 9 10 11 12 13 14 30 string app_id = 2; // 版本号, 如 "7.39.0" string app_version = 3; // 版本号, 如 "7390300" string app_version_code = 4; // 用户 mid string mid = 5; // 渠道号, 如 "master" string chid = 6; // APP 首次安装启动时间戳 int64 fts = 7; // 此处实际为 fp, 但不知为何命名为 buvid_local string buvid_local = 8; // 留空为 0 int32 first = 9; // 进程名, 如 "tv.danmaku.bili" string proc = 10; // 网络信息, 为一数组直接 toString() 的结果 // 如 """["dummy0,fe80::18d8:6ff:fe46:c2ba%dummy0,", "wlan0,fe80::a0f4:6dff:fea8:2d37%wlan0,192.168.1.5,", "lo,::1,127.0.0.1,", "rmnet_ims00,fe80::5a02:3ff:fe04:512%rmnet_ims00,2409:815a:7c38:cee1:1773:d0b9:d163:b023,"]""" string net = 11; // 手机无线电固件版本号(`Build.getRadioVersion()`). 如 `21C20B686S000C000,21C20B686S000C000`. string band = 12; // OS 版本号, 如 "12" string osver = 13; // 当前毫秒时间戳 int64 t = 14; // CPU 逻辑核心计数 int32 cpu_count = 15; // 手机 Model, 如 "NOH-AN01" string model = 16; // 手机品牌, 如 "HUAWEI" string brand = 17; // 屏幕信息, 如 "1288,2646,560", 即 "{width},{height},{pixel}" string screen = 18; // CPU 型号, 留空或根据实际情况确定 string cpu_model = 19; // 蓝牙 MAC, 留空或根据实际情况确定 string btmac = 20; // Linux 内核 bootid int64 boot = 21; // 模拟器(?), 如 "000" string emu = 22; // 移动网络 MCC MNC, 如中国移动为 46007 string oid = 23; // 当前网络类型, 如 "WIFI", 见 bilibili.metadata.network.NetworkType string network = 24; // 运行内存(Byte) int64 mem = 25; // 传感器信息, 为一数组直接 toString() 的结果 // 如 """["accelerometer-icm20690,invensense", "akm-akm09918,akm", "orientation,huawei", "als-tcs3718,ams", "proximity-tcs3718,ams", "gyroscope-icm20690,invensense", "gravity,huawei", "linear Acceleration,huawei", "rotation Vector,huawei", "airpress-bmp280,bosch", "HALL sensor,huawei", "uncalibrated Magnetic Field,Asahi Kasei Microdevices", "game Rotation Vector,huawei", "uncalibrated Gyroscope,STMicroelectronics", "significant Motion,huawei", "step Detector,huawei", "step counter,huawei", "geomagnetic Rotation Vector,huawei", "phonecall sensor,huawei", "RPC sensor,huawei", "agt,huawei", "color sensor,huawei", "uncalibrated Accelerometer,huawei", "drop sensor,huawei"]""" string sensor = 26; // CPU 频率, 如 2045000 int64 cpu_freq = 27; // CPU 架构, 如 "ARM" string cpu_vendor = 28; // ? string sim = 29; // 光照传感器数值 int32 brightness = 30; // Android Build.prop 信息, key 包括 net.hostname, ro.boot.hardware, etc. // 具体 key-value 需要技术手段自行确定 map props = 31; // 系统信息, key 包括 product, cpu_model_name, display, cpu_abi_list, etc. // 具体 key-value 需要技术手段自行确定 map sys = 32; // Wifi MAC, 一般无法获取, 留空 string wifimac = 33; // Android ID string adid = 34; // OS 名称, 如 "android" string os = 35; // IMEI, 一般无法获取, 留空 string imei = 36; // ?, 留空 string cell = 37; // IMSI, 一般无法获取, 留空 string imsi = 38; // ICCID, 一般无法获取, 留空 string iccid = 39; // 摄像头数量, 留空 int32 camcnt = 40; // 摄像头像素, 留空 string campx = 41; // 手机内置存储空间(Byte) int64 total_space = 42; // ?, 例如 "false" string axposed = 43; // ?, 留空 string maps = 44; // 如: "/data/user/0/tv.danmaku.bili/files" string files = 45; // 是否为虚拟化(?), 如 "0" string virtual = 46; // 虚拟进程, 如 "[]" string virtualproc = 47; // ?, 留空 string gadid = 48; // ?, 留空 string glimit = 49; // 设备安装的 APP 列表, 如 "[]" string apps = 50; // 客户端 GUID string guid = 51; // ?, 区分于用户 UID string uid = 52; // ?, 留空 int32 root = 53; // 摄像头放大倍数(?), 留空 string camzoom = 54; // 摄像头闪光灯(?), 留空 string camlight = 55; // OAID 匿名设备标识符, 参见 T/TAF 095-2021 安卓系统补充设备标识技术规范, 默认 "00000000-0000-0000-0000-000000000000" string oaid = 56; // UDID 设备唯一标识符, 参见 T/TAF 095-2021 安卓系统补充设备标识技术规范, 可留空 string udid = 57; // VAID 开发者匿名设备标识符, 参见 T/TAF 095-2021 安卓系统补充设备标识技术规范, 可留空 string vaid = 58; // AAID, 应用匿名设备标识符, 参见 T/TAF 095-2021 安卓系统补充设备标识技术规范, 可留空 string aaid = 59; // ?, 设置为 "[]" string androidapp20 = 60; // ?, 留空 int32 androidappcnt = 61; // ?, 设置为 "[]" string androidsysapp20 = 62; // 当前剩余电量, 如 100 int32 battery = 63; // Android 监听电量状态, 如 "BATTERY_STATUS_DISCHARGING" string battery_state = 64; // Wifi BSSID, 一般无法获取, 留空 string bssid = 65; // ?, 如 "NOH-AN01 4.0.0.102(DEVC00E100R7P5)" string build_id = 67; // ISO 国家代码, 如 "CN" string country_iso = 68; // 可用运行内存(Byte) int64 free_memory = 70; // 可用内置存储空间(Byte) string fstorage = 71; // Linux kernel version string kernel_version = 74; // 语言, 如 "zh" string languages = 75; // Wifi 网卡 MAC(?), 留空 string mac = 76; // 当前连接 Wifi 的 SSID, 留空 string ssid = 79; // ?, 留空 int32 systemvolume = 80; // Wifi 网卡 MAC 列表(?), 留空 string wifimaclist = 81; // 运行内存(Byte) int64 memory = 82; // 当前剩余电量, 如 "100" string str_battery = 83; // 设备是否 Root(?), 留空 bool is_root = 84; // 光照传感器数值字符串 string str_brightness = 85; // 产品id, 见 2 string str_app_id = 86; // 当前 IP(?), 留空 string ip = 87; // 留空即可 string user_agent = 88; // ?, 如: "1.25" string light_intensity = 89; // 设备 xyz 方向角度 repeated float device_angle = 90; // GPS 传感器数量(或者是否有 GPS 传感器?), 如 "1" int64 gps_sensor = 91; // 速度传感器数量(或者是否有速度传感器?), 如 "1" int64 speed_sensor = 92; // 线性加速度传感器数量(或者是否有线性加速度传感器?), 如 "1" int64 linear_speed_sensor = 93; // 陀螺仪传感器数量(或者是否有陀螺仪传感器?), 如 "1" int64 gyroscope_sensor = 94; // 生物识别传感器数量(或者是否有生物识别传感器?), 如 "1" int64 biometric = 95; // 生物识别传感器类型(?), 如 "touchid" repeated string biometrics = 96; // 上次 Crash Dump 时的毫秒时间戳 int64 last_dump_ts = 97; // 留空即可 string location = 98; // 留空即可 string country = 99; // 留空即可 string city = 100; // ?, 默认为 0 int32 data_activity_state = 101; // ?, 默认为 0 int32 data_connect_state = 102; // ?, 默认为 0 int32 data_network_type = 103; // ?, 默认为 0 int32 voice_network_type = 104; // ?, 默认为 0 int32 voice_service_state = 105; // USB 是否连接, 启用为 "1", 否则为 "0" int32 usb_connected = 106; // ADB 是否启用, 启用为 "1", 否则为 "0" int32 adb_enabled = 107; // 系统 UI 软件版本(?), 如 "14.0.0" string ui_version = 108; // 辅助服务 repeated string accessibility_service = 109; // 传感器信息(需要和前面的 sensor 对应) repeated SensorInfo sensors_info = 110; // DrmId string drmid = 111; // 是否存在电池 bool battery_present = 112; // 电池技术, 如 "Li-poly" string battery_technology = 113; // 电池温度(m℃) int32 battery_temperature = 114; // 电池电压(mV) int32 battery_voltage = 115; // 电池是否被拔开(?), 可以为 0 int32 battery_plugged = 116; // 电池健康, 如 2 int32 battery_health = 117; // 留空即可 repeated string cpu_abi_list = 118; // 留空即可 string cpu_abi_libc = 119; // 留空即可 string cpu_abi_libc64 = 120; // 留空即可 string cpu_processor = 121; // 留空即可 string cpu_model_name = 122; // 留空即可 string cpu_hardware = 123; // 留空即可 string cpu_features = 124; } // 传感器信息 message SensorInfo { // 传感器名称, 如 "rotation Vector" string name = 1; // 制造商 string vendor = 2; // int32 version = 3; // int32 type = 4; // float max_range = 5; // float resolution = 6; // 耗电量(mA) float power = 7; // int32 min_delay = 8; } ================================================ FILE: bili-api/grpc/proto/pgc/biz/room.proto ================================================ syntax = "proto3"; package pgc.biz; option java_multiple_files = true; // message RoomProto { // repeated string room_id = 1; } ================================================ FILE: bili-api/grpc/proto/pgc/gateway/vega/v1/vega.proto ================================================ syntax = "proto3"; package pgc.gateway.vega.v1; option java_multiple_files = true; import "bilibili/rpc/status.proto"; import "google/protobuf/any.proto"; import "google/protobuf/empty.proto"; // service Vega { // rpc CreateTunnel (VegaFrame) returns (VegaFrame); } // service VegaFrameDoc { // rpc Auth (AuthReq) returns (AuthResp); // rpc Heartbeat (HeartbeatReq) returns (HeartbeatResp); // rpc MessageAck (MessageAckReq) returns (google.protobuf.Empty); // rpc Subscribe (SubscribeReq) returns (google.protobuf.Empty); } // message AuthReq {} // message AuthResp {} // message FrameOption { // int64 vega_id = 1; // string req_id = 2; // int64 sequence = 3; // bool is_ack = 4; // bilibili.rpc.Status status = 5; // string ack_origin = 6; // int64 mid = 7; } // message HeartbeatReq {} // message HeartbeatResp {} // message MessageAckReq { // string vega_id = 1; // string req_id = 2; // string origin = 3; // string target_path = 4; } // message SubscribeReq { // repeated TargetPath target_paths = 1; } // message TargetPath { // string key = 1; // google.protobuf.Any subs = 2; } // message VegaFrame { // FrameOption options = 1; // string route_path = 2; // google.protobuf.Any body = 3; // google.protobuf.Any sub_biz = 4; } ================================================ FILE: bili-api/src/main/kotlin/com/tfowl/ktor/client/plugins/JsoupPlugin.kt ================================================ @file:Suppress("unused") package com.tfowl.ktor.client.plugins import io.ktor.client.* import io.ktor.client.plugins.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.util.* import io.ktor.utils.io.* import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.parser.Parser /** * [HttpClient] plugin that parses response bodies into Jsoup [Document] * class using a provided [Parser] * * By default, * * [ContentType.Text.Html] is parsed using [Parser.htmlParser]. * * [ContentType.Text.Xml] & [ContentType.Application.Xml] are parsed using [Parser.xmlParser]. * * Note: It will only parse registered content types and for receiving * [Document] or superclasses. * * @property parsers Registered parsers for content types */ class JsoupPlugin internal constructor(val parsers: Map) { /** * [JsoupPlugin] configuration that is used during installation */ class Config { /** * [Parsers][Parser] that will be used for each [ContentType] * * Defaults: * - Html: [ContentType.Text.Html] * - Xml: [ContentType.Text.Xml] and [ContentType.Application.Xml] */ var parsers = mutableMapOf( ContentType.Text.Html to Parser.htmlParser(), ContentType.Text.Xml to Parser.xmlParser(), ContentType.Application.Xml to Parser.xmlParser() ) } /** * Companion object for plugin installation */ companion object Plugin : HttpClientPlugin { override val key: AttributeKey = AttributeKey("Jsoup") override fun prepare(block: Config.() -> Unit): JsoupPlugin = JsoupPlugin(Config().apply(block).parsers) override fun install(plugin: JsoupPlugin, scope: HttpClient) { scope.responsePipeline.intercept(HttpResponsePipeline.Transform) { (info, body) -> if (body !is ByteReadChannel) return@intercept if (!info.type.java.isAssignableFrom(Document::class.java)) return@intercept val responseContentType = context.response.contentType() ?: return@intercept val parser = plugin.parsers.firstNotNullOfOrNull { (type, parser) -> parser.takeIf { responseContentType.match(type) } } ?: return@intercept val bodyContent = body.readRemaining().readText() val baseUri = context.request.url.toString() /* Jsoup Parsers internally contain a stateful TreeBuilder, We need to create a deep copy to avoid issues with concurrency */ val document = Jsoup.parse(bodyContent, baseUri, parser.newInstance()) proceedWith(HttpResponseContainer(info, document)) } } } } /** * Install [JsoupPlugin] */ @Suppress("FunctionName") fun HttpClientConfig<*>.Jsoup(block: JsoupPlugin.Config.() -> Unit = {}) { install(JsoupPlugin, block) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/BiliApiConstants.kt ================================================ package dev.aaa1115910.biliapi object BiliApiConstants { const val USER_AGENT_WEB = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" const val USER_AGENT_APP = "Bilibili Freedoooooom/MarkII" } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ApiType.kt ================================================ package dev.aaa1115910.biliapi.entity enum class ApiType { Web, App } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/CarouselData.kt ================================================ package dev.aaa1115910.biliapi.entity import dev.aaa1115910.biliapi.util.UrlUtil import dev.aaa1115910.biliapi.util.toBv import io.ktor.http.Url data class CarouselData( val items: List ) { companion object { fun fromPgcWebInitialStateData(data: dev.aaa1115910.biliapi.http.entity.pgc.PgcWebInitialStateData): CarouselData { val result = mutableListOf() var needParseIdFromUrl = false // 电影、电视剧、综艺板块里的轮播图数据里没有直接包含 episodeId 和 seasonId if (listOf(1668, 1675, 1682).contains(data.modules.banner.moduleId)) needParseIdFromUrl = true data.modules.banner.items.filter { it.episodeId != null || (needParseIdFromUrl && it.link.contains("bangumi/play/ep")) || (needParseIdFromUrl && it.link.contains("bangumi/play/ss")) }.forEach { var cover = it.bigCover ?: it.cover if (cover.startsWith("//")) cover = "https:$cover" var epidFromUrl: Int? = null var ssidFromUrl: Int? = null if (needParseIdFromUrl) { val idStr = Url(it.link).rawSegments.last() epidFromUrl = idStr.substring(2).takeIf { idStr.startsWith("ep") }?.toIntOrNull() ssidFromUrl = idStr.substring(2).takeIf { idStr.startsWith("ss") }?.toIntOrNull() } result.add( CarouselItem( cover = cover, title = it.title, seasonId = it.seasonId ?: ssidFromUrl ?: -1, episodeId = it.episodeId ?: epidFromUrl ?: -1 ) ) } return CarouselData(result) } fun fromUgcRegionDynamicBanner(data: dev.aaa1115910.biliapi.http.entity.region.RegionDynamic.Banner): CarouselData { val result = mutableListOf() data.top.forEach { top -> if (!UrlUtil.isVideoUrl(top.uri)) return@forEach val avid = UrlUtil.parseAidFromUrl(top.uri) val bvid = avid.toBv() result.add( CarouselItem( cover = top.image, title = top.title, avid = avid, bvid = bvid ) ) } return CarouselData(result) } fun fromUgcRegionLocs(data: dev.aaa1115910.biliapi.http.entity.region.RegionLocs): CarouselData { val result = mutableListOf() data.data.forEach { (_, value) -> value.filter { it.url.contains("/video/") }.forEach { item -> result.add( CarouselItem( cover = item.pic, title = item.title, bvid = Url(item.url).rawSegments.last() ) ) } } return CarouselData(result) } fun fromRegionBanner(data: dev.aaa1115910.biliapi.http.entity.region.RegionBanner): CarouselData { val result = mutableListOf() data.regionBannerList.forEach { item -> if (!UrlUtil.isVideoUrl(item.url)) return@forEach val avid = UrlUtil.parseAidFromUrl(item.url) val bvid = avid.toBv() result.add( CarouselItem( cover = item.image, title = item.title, avid = avid, bvid = bvid ) ) } return CarouselData(result) } } data class CarouselItem( val cover: String, val title: String, val seasonId: Int? = null, val episodeId: Int? = null, val avid: Long? = null, val bvid: String? = null ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/CodeType.kt ================================================ package dev.aaa1115910.biliapi.entity import bilibili.pgc.gateway.player.v2.CodeType as PgcPlayUrlCodeType import bilibili.playershared.CodeType as PlayerSharedCodeType enum class CodeType(val str: String, val codecId: Int) { NoCode("none", 0), Code264("avc1", 7), Code265("hev1", 12), CodeAv1("av01", 13), Unrecognized("unknown", 0); companion object { fun fromCodecId(code: Int?) = runCatching { entries.find { it.codecId == code }!! }.getOrDefault(NoCode) } fun toPlayerSharedCodeType() = when (this) { NoCode -> PlayerSharedCodeType.NOCODE Code264 -> PlayerSharedCodeType.CODE264 Code265 -> PlayerSharedCodeType.CODE265 CodeAv1 -> PlayerSharedCodeType.CODEAV1 Unrecognized -> PlayerSharedCodeType.UNRECOGNIZED } fun toPgcPlayUrlCodeType() = when (this) { NoCode, CodeAv1 -> PgcPlayUrlCodeType.NOCODE Code264 -> PgcPlayUrlCodeType.CODE264 Code265 -> PgcPlayUrlCodeType.CODE265 Unrecognized -> PgcPlayUrlCodeType.UNRECOGNIZED } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/Favorite.kt ================================================ package dev.aaa1115910.biliapi.entity import dev.aaa1115910.biliapi.http.entity.user.favorite.CntInfo import dev.aaa1115910.biliapi.http.entity.user.favorite.FavoriteItemId import kotlinx.serialization.Serializable data class FavoriteFolderItemId( val id: Long, val type: FavoriteItemType, val bvid: String ) { companion object { fun fromFavoriteItemId(favoriteItemId: FavoriteItemId): FavoriteFolderItemId { return FavoriteFolderItemId( id = favoriteItemId.id, type = FavoriteItemType.fromValue(favoriteItemId.type), bvid = favoriteItemId.bvid ) } } } enum class FavoriteItemType(val value: Int) { All(0), Video(2), Audio(12), VideoCollection(21); companion object { fun fromValue(typeId: Int) = entries.first { it.value == typeId } } } /** * 收藏夹元数据 * * @param id 收藏夹mlid(完整id) 收藏夹原始id+创建者mid尾号2位 * @param fid 收藏夹原始id * @param mid 创建者mid * @param title 收藏夹标题 * @param cover 收藏夹封面图片url * @param mediaCount 收藏夹内容数量 */ data class FavoriteFolderMetadata( val id: Long, val fid: Long, val mid: Long, val title: String, val cover: String?, var videoInThisFav: Boolean, val mediaCount: Int ) { companion object { fun fromHttpFavoriteFolderInfo(httpFavoriteFolderInfo: dev.aaa1115910.biliapi.http.entity.user.favorite.FavoriteFolderInfo): FavoriteFolderMetadata { return FavoriteFolderMetadata( id = httpFavoriteFolderInfo.id, fid = httpFavoriteFolderInfo.fid, mid = httpFavoriteFolderInfo.mid, title = httpFavoriteFolderInfo.title, cover = httpFavoriteFolderInfo.cover, videoInThisFav = httpFavoriteFolderInfo.favState == 1, mediaCount = httpFavoriteFolderInfo.mediaCount ) } fun fromHttpUserFavoriteFolder(httpUserFavoriteFoldersData: dev.aaa1115910.biliapi.http.entity.user.favorite.UserFavoriteFoldersData.UserFavoriteFolder): FavoriteFolderMetadata { return FavoriteFolderMetadata( id = httpUserFavoriteFoldersData.id, fid = httpUserFavoriteFoldersData.fid, mid = httpUserFavoriteFoldersData.mid, title = httpUserFavoriteFoldersData.title, cover = null, videoInThisFav = httpUserFavoriteFoldersData.favState == 1, mediaCount = httpUserFavoriteFoldersData.mediaCount ) } } } data class FavoriteFolderData( val info: FavoriteFolderMetadata, val medias: List, val hasMore: Boolean ) { companion object { fun fromHttpFavoriteFolderInfoListData(httpFavoriteFolderInfoListData: dev.aaa1115910.biliapi.http.entity.user.favorite.FavoriteFolderInfoListData): FavoriteFolderData { return FavoriteFolderData( info = FavoriteFolderMetadata.fromHttpFavoriteFolderInfo( httpFavoriteFolderInfoListData.info ), medias = httpFavoriteFolderInfoListData.medias.map { FavoriteItem.fromHttpFavoriteItem( it ) }, hasMore = httpFavoriteFolderInfoListData.hasMore ) } } } data class FavoriteItem( val id: Long, val type: FavoriteItemType, val title: String, val cover: String, val intro: String, val page: Int, val duration: Int, val upper: Upper, val link: String, val favTime: Long, val bvid: String, val cntInfo: CntInfo ) { companion object { fun fromHttpFavoriteItem(httpFavoriteItem: dev.aaa1115910.biliapi.http.entity.user.favorite.FavoriteItem): FavoriteItem { return FavoriteItem( id = httpFavoriteItem.id, type = FavoriteItemType.fromValue(httpFavoriteItem.type), title = httpFavoriteItem.title, cover = httpFavoriteItem.cover, intro = httpFavoriteItem.intro, page = httpFavoriteItem.page, duration = httpFavoriteItem.duration, upper = Upper.fromHttpUpper(httpFavoriteItem.upper), link = httpFavoriteItem.link, favTime = httpFavoriteItem.favTime, bvid = httpFavoriteItem.bvid, cntInfo = httpFavoriteItem.cntInfo ) } } } @Serializable data class Upper( val mid: Long, val name: String, val face: String ) { companion object { fun fromHttpUpper(httpUpper: dev.aaa1115910.biliapi.http.entity.user.favorite.Upper): Upper { return Upper( mid = httpUpper.mid, name = httpUpper.name, face = httpUpper.face ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/Picture.kt ================================================ package dev.aaa1115910.biliapi.entity import java.util.UUID /** * 评论图片 * * @param url 图片链接 * @param width 图片宽度 * @param height 图片高度 * @param key 使用 [com.origeek.imageViewer.previewer.TransformImageView] [com.origeek.imageViewer.previewer.ImagePreviewer] 浏览图片缩放时需要用到的 key */ data class Picture( val url: String, val width: Int, val height: Int, val key: String ) { companion object { private fun normalizeUrl(url: String): String { return when { url.startsWith("http://") -> url.replaceFirst("http://", "https://") else -> url } } fun fromPicture(picture: dev.aaa1115910.biliapi.http.entity.reply.CommentData.Reply.Content.Picture): Picture { return Picture( url = normalizeUrl(picture.imgSrc), width = picture.imgWidth, height = picture.imgHeight, key = UUID.randomUUID().toString() ) } fun fromPicture(picture: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic.Major.Opus.Pic): Picture { return Picture( url = normalizeUrl(picture.url), width = picture.width, height = picture.height, key = UUID.randomUUID().toString() ) } fun fromPicture(picture: bilibili.main.community.reply.v1.Picture): Picture { return Picture( url = normalizeUrl(picture.imgSrc), width = picture.imgWidth.toInt(), height = picture.imgHeight.toInt(), key = UUID.randomUUID().toString() ) } fun fromPicture(picture: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic.Major.Draw.Pic): Picture { return Picture( url = normalizeUrl(picture.src), width = picture.width, height = picture.height, key = UUID.randomUUID().toString() ) } fun fromPicture(picture: bilibili.app.dynamic.v2.MdlDynDrawItem): Picture { return Picture( url = normalizeUrl(picture.src), width = picture.width.toInt(), height = picture.height.toInt(), key = UUID.randomUUID().toString() ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/PlayData.kt ================================================ package dev.aaa1115910.biliapi.entity import bilibili.app.playerunite.v1.PlayViewUniteReply import bilibili.pgc.gateway.player.v2.dashVideoOrNull import bilibili.pgc.gateway.player.v2.dolbyOrNull import bilibili.playershared.dashVideoOrNull import bilibili.playershared.dolbyOrNull import bilibili.playershared.lossLessItemOrNull import bilibili.playershared.segmentVideoOrNull import dev.aaa1115910.biliapi.http.entity.video.ClipInfo data class PlayData( val dashVideos: List, val dashAudios: List, val dolby: DashAudio? = null, val flac: DashAudio? = null, val codec: Map> = emptyMap(), val needPay: Boolean = false, val clipInfoList: List = emptyList(), ) { companion object { fun fromPlayViewUniteReply(playViewUniteReply: PlayViewUniteReply): PlayData { val vodInfo = playViewUniteReply.vodInfo // 过滤出有 dashVideo 的流 val dashVideoStreams = vodInfo.streamListList.filter { it.dashVideoOrNull != null } // 过滤出有 segmentVideo 的流(试看流) val segmentVideoStreams = vodInfo.streamListList.filter { it.segmentVideoOrNull != null } val audioList = vodInfo.dashAudioList val dolbyItem = vodInfo.dolbyOrNull?.audioList?.firstOrNull() val lossLessItem = vodInfo.lossLessItemOrNull?.audio.takeIf { it?.id != 0 } // 处理 dashVideo val dashVideos = dashVideoStreams.map { DashVideo( quality = it.streamInfo.quality, baseUrl = it.dashVideo.baseUrl, bandwidth = it.dashVideo.bandwidth, codecId = it.dashVideo.codecid, width = it.dashVideo.width, height = it.dashVideo.height, frameRate = it.dashVideo.frameRate, backUrl = it.dashVideo.backupUrlList, codecs = CodeType.fromCodecId(it.dashVideo.codecid).str ) }.toMutableList() val isPreview = dashVideos.isEmpty() && segmentVideoStreams.isNotEmpty() // 当 dashVideo 不存在时,使用 segmentVideo(试看流)的 durl 填充 if (isPreview) { segmentVideoStreams.forEach { stream -> val firstSegment = stream.segmentVideo.segmentList.firstOrNull() if (firstSegment != null) { dashVideos.add( DashVideo( quality = stream.streamInfo.quality, baseUrl = firstSegment.url, bandwidth = 0, codecId = stream.streamInfo.quality, width = 0, height = 0, frameRate = "", backUrl = firstSegment.backupUrlList, codecs = CodeType.fromCodecId(stream.streamInfo.quality).str ) ) } } } val dashAudios = audioList.map { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrlList ) } val dolby = dolbyItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrlList ) } val flac = lossLessItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrlList ) } // 生成 codec 映射(优先使用 dashVideo,如果没有则使用 segmentVideo) val codecs = if (dashVideoStreams.isNotEmpty()) { dashVideoStreams.associate { it.streamInfo.quality to listOf(CodeType.fromCodecId(it.dashVideo.codecid).str) } } else { segmentVideoStreams.associate { it.streamInfo.quality to listOf(CodeType.fromCodecId(it.streamInfo.quality).str) } } return PlayData( dashVideos = dashVideos, dashAudios = dashAudios, dolby = dolby, flac = flac, codec = codecs, needPay = isPreview ) } fun fromPgcPlayViewReply(pgcPlayViewReply: bilibili.pgc.gateway.player.v2.PlayViewReply): PlayData { val streamList = pgcPlayViewReply.videoInfo.streamListList.filter { it.dashVideoOrNull != null } val audioList = pgcPlayViewReply.videoInfo.dashAudioList val dolbyItem = pgcPlayViewReply.videoInfo.dolbyOrNull?.audio val codecs = pgcPlayViewReply.videoInfo.streamListList.associate { it.info.quality to listOf(CodeType.fromCodecId(it.dashVideo.codecid).str) } val needPay = pgcPlayViewReply.business.isPreview val dashVideos = streamList.map { DashVideo( quality = it.info.quality, baseUrl = it.dashVideo.baseUrl, bandwidth = it.dashVideo.bandwidth, codecId = it.dashVideo.codecid, width = it.dashVideo.width, height = it.dashVideo.height, frameRate = it.dashVideo.frameRate, backUrl = it.dashVideo.backupUrlList, codecs = CodeType.fromCodecId(it.dashVideo.codecid).str ) } val dashAudios = audioList.map { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrlList ) } val dolby = dolbyItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.codecid, backUrl = it.backupUrlList ) } return PlayData( dashVideos = dashVideos, dashAudios = dashAudios, dolby = dolby, flac = null, codec = codecs, needPay = needPay ) } fun fromPlayUrlV2Data(playUrlV2Data: dev.aaa1115910.biliapi.http.entity.video.PlayUrlV2Data): PlayData { return fromPlayUrlData(playUrlV2Data.videoInfo) } fun fromPlayUrlData(playUrlData: dev.aaa1115910.biliapi.http.entity.video.PlayUrlData): PlayData { val hasDash = playUrlData.dash != null val isPreview = !hasDash && playUrlData.durl.isNotEmpty() val audios = playUrlData.dash?.audio val dolbyItem = playUrlData.dash?.dolby?.audio?.firstOrNull() val flacItem = playUrlData.dash?.flac?.audio val codec = if (hasDash) { playUrlData.supportFormats .mapNotNull { it.codecs?.let { c -> it.quality to c } } .toMap() } else { mapOf(playUrlData.quality to listOf(CodeType.fromCodecId(playUrlData.videoCodecId).str)) } val dashVideos = if (hasDash) { playUrlData.dash!!.video.map { DashVideo( quality = it.id, baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, width = it.width, height = it.height, frameRate = it.frameRate, backUrl = it.backupUrl, codecs = it.codecs ) } } else { // 充电视频未付费状态下没有 dash,只有试看流 durl,转成 DASH 结构 playUrlData.durl.map { DashVideo( quality = playUrlData.quality, baseUrl = it.url, backUrl = it.backupUrl, codecId = playUrlData.videoCodecId, bandwidth = 0, width = 0, height = 0, frameRate = "", codecs = "" ) } } val dashAudios = audios?.map { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } ?: emptyList() val dolby = dolbyItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } val flac = flacItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } return PlayData( dashVideos = dashVideos, dashAudios = dashAudios, dolby = dolby, flac = flac, codec = codec, needPay = isPreview, clipInfoList = playUrlData.clipInfoList ) } fun fromPlayUrlData(playUrlData: dev.aaa1115910.biliapi.http.entity.proxy.ProxyWebPlayUrlData): PlayData { val hasDash = playUrlData.dash != null val isPreview = !hasDash && playUrlData.durl.isNotEmpty() val audios = playUrlData.dash?.audio val dolbyItem = playUrlData.dash?.dolby?.audio?.firstOrNull() val flacItem = playUrlData.dash?.flac?.audio val codec = if (hasDash) { playUrlData.supportFormats .mapNotNull { it.codecs?.let { c -> it.quality to c } } .toMap() } else { mapOf(playUrlData.quality to listOf(CodeType.fromCodecId(playUrlData.videoCodecId).str)) } val dashVideos = if (hasDash) { playUrlData.dash!!.video.map { DashVideo( quality = it.id, baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, width = it.width, height = it.height, frameRate = it.frameRate, backUrl = it.backupUrl, codecs = it.codecs ) } } else { playUrlData.durl.map { DashVideo( quality = playUrlData.quality, baseUrl = it.url, backUrl = it.backupUrl, codecId = playUrlData.videoCodecId, bandwidth = 0, width = 0, height = 0, frameRate = "", codecs = "" ) } } val dashAudios = audios?.map { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } ?: emptyList() val dolby = dolbyItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } val flac = flacItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } return PlayData( dashVideos = dashVideos, dashAudios = dashAudios, dolby = dolby, flac = flac, codec = codec, needPay = isPreview, clipInfoList = playUrlData.clipInfoList ) } fun fromPlayUrlData(playUrlData: dev.aaa1115910.biliapi.http.entity.proxy.ProxyAppPlayUrlData): PlayData { val hasDash = playUrlData.dash != null val isPreview = !hasDash && playUrlData.durl.isNotEmpty() val audios = playUrlData.dash?.audio val dolbyItem = playUrlData.dash?.dolby?.audio?.firstOrNull() val flacItem = playUrlData.dash?.flac?.audio val codec = if (hasDash) { playUrlData.supportFormats .mapNotNull { it.codecs?.let { c -> it.quality to c } } .toMap() } else { mapOf(playUrlData.quality to listOf(CodeType.fromCodecId(playUrlData.videoCodecId).str)) } val dashVideos = if (hasDash) { playUrlData.dash!!.video.map { DashVideo( quality = it.id, baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, width = it.width, height = it.height, frameRate = it.frameRate, backUrl = it.backupUrl, codecs = it.codecs ) } } else { playUrlData.durl.map { DashVideo( quality = playUrlData.quality, baseUrl = it.url, backUrl = it.backupUrl, codecId = playUrlData.videoCodecId, bandwidth = 0, width = 0, height = 0, frameRate = "", codecs = "" ) } } val dashAudios = audios?.map { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } ?: emptyList() val dolby = dolbyItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } val flac = flacItem?.let { DashAudio( baseUrl = it.baseUrl, bandwidth = it.bandwidth, codecId = it.id, backUrl = it.backupUrl ) } return PlayData( dashVideos = dashVideos, dashAudios = dashAudios, dolby = dolby, flac = flac, codec = codec, needPay = isPreview, clipInfoList = playUrlData.clipInfoList ) } } operator fun plus(other: PlayData): PlayData { return PlayData( dashVideos = (dashVideos + other.dashVideos) .distinctBy { "${it.codecId}_${it.quality}" } .sortedByDescending { it.quality }, dashAudios = (dashAudios + other.dashAudios) .distinctBy { it.codecId } .sortedByDescending { it.codecId }, dolby = dolby ?: other.dolby, flac = flac ?: other.flac, codec = codec.map { it.key to (it.value + other.codec[it.key].orEmpty()) .distinct() .filter { it != "none" } }.toMap(), needPay = needPay || other.needPay, clipInfoList = clipInfoList + other.clipInfoList ) } } /** * @param quality 视频分辨率 * @param baseUrl 主线流 * @param bandwidth 码率 * @param codecId 编码ID * @param width 视频宽度 * @param height 视频高度 * @param frameRate 帧率 * @param backUrl 备用流 * @param codecs 编码格式 仅 Web 接口有该值 */ data class DashVideo( val quality: Int, val baseUrl: String, val bandwidth: Int, val codecId: Int, val width: Int, val height: Int, val frameRate: String, val backUrl: List, val codecs: String? = null ) /** * @param baseUrl 主线流 * @param bandwidth 码率 * @param codecId 编码ID * @param backUrl 备用流 */ data class DashAudio( val baseUrl: String, val bandwidth: Int, val codecId: Int, val backUrl: List ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/danmaku/DanmakuMask.kt ================================================ package dev.aaa1115910.biliapi.entity.danmaku import io.ktor.util.decodeBase64String import okio.ByteString.Companion.readByteString import java.io.ByteArrayInputStream import java.io.DataInputStream import java.util.zip.GZIPInputStream data class DanmakuMaskSegment( val range: LongRange, val frames: List, ) sealed class DanmakuMaskFrame( open val range: LongRange ) data class DanmakuWebMaskFrame( override val range: LongRange, val svg: String ) : DanmakuMaskFrame(range) data class DanmakuMobMaskFrame( override val range: LongRange, val width: Int, val height: Int, val image: ByteArray ) : DanmakuMaskFrame(range) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as DanmakuMobMaskFrame if (range != other.range) return false if (width != other.width) return false if (height != other.height) return false if (!image.contentEquals(other.image)) return false return true } override fun hashCode(): Int { var result = range.hashCode() result = 31 * result + width result = 31 * result + height result = 31 * result + image.contentHashCode() return result } } data class DanmakuMask( val type: DanmakuMaskType, val segments: List ) { companion object { fun fromBinary(binary: ByteArray, type: DanmakuMaskType): DanmakuMask { val times = mutableListOf() val offsets = mutableListOf() val danmakuMaskSegments = mutableListOf() val inputStream = DataInputStream(ByteArrayInputStream(binary)) val mask = inputStream.readByteString(4) require(mask.string(Charsets.UTF_8) == "MASK") { "Not a mask file" } val version = inputStream.readInt() val unused = inputStream.readByteString(4) val size = inputStream.readInt() for (i in 0 until size) { times.add(inputStream.readLong()) offsets.add(inputStream.readLong()) } var lastTime = 0L var segLastTime = 0L for (i in 0 until size) { val frameList = mutableListOf() val bytes = if (i == size - 1) { inputStream.readBytes() } else { val offDiff = (offsets[i + 1] - offsets[i]).toInt() val byteArray = ByteArray(offDiff) inputStream.read(byteArray) byteArray } val stream = DataInputStream(ByteArrayInputStream(GZIPInputStream(bytes.inputStream()).readBytes())) while (stream.available() != 0) { when (type) { DanmakuMaskType.WebMask -> { val svgLength = stream.readInt() val time = stream.readLong() val svg = stream.readByteString(svgLength).string(Charsets.UTF_8) val svgParts = svg.split(",") if (svgParts.size < 2) { throw IllegalArgumentException("Invalid SVG format") } val decodedSvg = svgParts[1] .replace("\n", "") .decodeBase64String() frameList.add( DanmakuWebMaskFrame( range = lastTime.. { val width = stream.readShort().toInt() val height = stream.readShort().toInt() val time = stream.readLong() val imageSize = (width * height + 7) / 8 // 1bpp val imageBinary = ByteArray(imageSize) stream.read(imageBinary) frameList.add( DanmakuMobMaskFrame( range = lastTime.., val nextPage: RecommendPage ) data class RecommendPage( val nextWebIdx: Int = 1, val nextAppIdx: Int = 0 ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/live/LiveArea.kt ================================================ package dev.aaa1115910.biliapi.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class LiveAreaResponse( val code: Int, val msg: String, val message: String, val data: List = emptyList() ) @Serializable data class LiveAreaGroup( val id: Int, val name: String, val list: List = emptyList() ) @Serializable data class LiveAreaItem( val id: String, @SerialName("parent_id") val parentId: String, @SerialName("old_area_id") val oldAreaId: String, val name: String, val pic: String, @SerialName("parent_name") val parentName: String, @SerialName("area_type") val areaType: Int ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/live/LiveFollowing.kt ================================================ package dev.aaa1115910.biliapi.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * 关注的直播间列表响应 * API: https://api.live.bilibili.com/xlive/web-ucenter/user/following */ @Serializable data class LiveFollowingResponse( val code: Int, val message: String, val data: LiveFollowingData? = null ) @Serializable data class LiveFollowingData( @SerialName("totalPage") val totalPage: Int = 0, val count: Int = 0, val list: List = emptyList() ) @Serializable data class LiveFollowingRoom( @SerialName("roomid") val roomId: Int = 0, val uid: Long = 0, val title: String = "", val uname: String = "", @SerialName("room_cover") val roomCover: String = "", @SerialName("cover_from_user") val coverFromUser: String = "", val face: String = "", @SerialName("text_small") val textSmall: String = "", @SerialName("live_status") val liveStatus: Int = 0, @SerialName("parent_area_id") val parentAreaId: Int = 0, @SerialName("area_v2_parent_name") val parentAreaName: String = "", @SerialName("area_id") val areaId: Int = 0, @SerialName("area_name_v2") val areaNameV2: String = "", @SerialName("area_name") val areaName: String = "", @SerialName("watched_show") val watchedShow: WatchedShow? = null ) { /** 是否正在直播 */ val isLive: Boolean get() = liveStatus == 1 /** 封面优先使用 room_cover,降级到 cover_from_user */ val coverUrl: String get() = roomCover.ifBlank { coverFromUser } /** 解析中文数字格式(如 "1.2万")为整数 */ val onlineCount: Int get() = parseCnCount(textSmall) fun toLiveRoomItem(): LiveRoomItem = LiveRoomItem( roomId = roomId, uid = uid, title = title, uname = uname, online = onlineCount, userCover = coverUrl, cover = coverUrl, face = face, parentId = parentAreaId, parentName = parentAreaName, areaId = areaId, areaName = areaNameV2.ifBlank { areaName }, watchedShow = watchedShow, liveStatus = liveStatus ) companion object { /** * 解析中文计数格式(如 "1.2万")为整数 */ private fun parseCnCount(text: String): Int { if (text.isBlank()) return 0 val trimmed = text.trim() return when { trimmed.endsWith("万") -> { val num = trimmed.removeSuffix("万").toDoubleOrNull() ?: return 0 (num * 10000).toInt() } trimmed.endsWith("亿") -> { val num = trimmed.removeSuffix("亿").toDoubleOrNull() ?: return 0 (num * 100000000).toInt() } else -> trimmed.toIntOrNull() ?: 0 } } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/live/LiveRecommend.kt ================================================ package dev.aaa1115910.biliapi.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * 推荐直播列表响应(主端点) * API: https://api.live.bilibili.com/xlive/web-interface/v1/webMain/getMoreRecList */ @Serializable data class LiveRecommendResponse( val code: Int, val message: String, val data: LiveRecommendData? = null ) @Serializable data class LiveRecommendData( @SerialName("recommend_room_list") val recommendRoomList: List = emptyList() ) @Serializable data class LiveRecommendRoom( @SerialName("roomid") val roomId: Int = 0, val uid: Long = 0, val title: String = "", val uname: String = "", val online: Int = 0, @SerialName("user_cover") val userCover: String = "", val cover: String = "", val face: String = "", @SerialName("area_v2_parent_id") val parentAreaId: Int = 0, @SerialName("area_v2_parent_name") val parentAreaName: String = "", @SerialName("area_v2_id") val areaId: Int = 0, @SerialName("area_v2_name") val areaName: String = "", val keyframe: String = "", @SerialName("watched_show") val watchedShow: WatchedShow? = null ) { fun toLiveRoomItem(): LiveRoomItem = LiveRoomItem( roomId = roomId, uid = uid, title = title, uname = uname, online = online, userCover = userCover, cover = cover, face = face, parentId = parentAreaId, parentName = parentAreaName, areaId = areaId, areaName = areaName, watchedShow = watchedShow ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/live/LiveRoom.kt ================================================ package dev.aaa1115910.biliapi.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class LiveRoomListResponse( val code: Int, val message: String, val data: LiveRoomListData = LiveRoomListData() ) @Serializable data class LiveRoomListData( val list: List = emptyList() ) @Serializable data class LiveRoomItem( @SerialName("roomid") val roomId: Int = 0, val uid: Long = 0, val title: String = "", val uname: String = "", val online: Int = 0, @SerialName("user_cover") val userCover: String = "", @SerialName("system_cover") val systemCover: String = "", val cover: String = "", val face: String = "", @SerialName("parent_id") val parentId: Int = 0, @SerialName("parent_name") val parentName: String = "", @SerialName("area_id") val areaId: Int = 0, @SerialName("area_name") val areaName: String = "", @SerialName("watched_show") val watchedShow: WatchedShow? = null, /** 直播状态:1=直播中, 0=未开播。非序列化字段,由 toLiveRoomItem 注入 */ @kotlinx.serialization.Transient val liveStatus: Int = 1 ) @Serializable data class WatchedShow( val switch: Boolean = false, val num: Int = 0, @SerialName("text_small") val textSmall: String = "", @SerialName("text_large") val textLarge: String = "", val icon: String = "", @SerialName("icon_location") val iconLocation: Int = 0, @SerialName("icon_web") val iconWeb: String = "" ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/live/LiveRoomPlayInfo.kt ================================================ package dev.aaa1115910.biliapi.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class LiveRoomPlayInfoResponse( val code: Int, val message: String, val data: LiveRoomPlayInfoData? = null ) @Serializable data class LiveRoomPlayInfoData( @SerialName("room_id") val roomId: Int, @SerialName("short_id") val shortId: Int, val uid: Long, @SerialName("live_status") val liveStatus: Int, // 0=未开播, 1=直播中 @SerialName("is_portrait") val isPortrait: Boolean, @SerialName("playurl_info") val playUrlInfo: LivePlayUrlInfo? = null ) @Serializable data class LivePlayUrlInfo( val playurl: LivePlayUrl? = null ) @Serializable data class LiveQnDesc( val qn: Int, val desc: String ) @Serializable data class LivePlayUrl( val stream: List = emptyList(), @SerialName("g_qn_desc") val gQnDesc: List = emptyList() ) @Serializable data class LiveStream( @SerialName("protocol_name") val protocolName: String, val format: List = emptyList() ) @Serializable data class LiveFormat( @SerialName("format_name") val formatName: String, val codec: List = emptyList() ) @Serializable data class LiveCodec( @SerialName("codec_name") val codecName: String, @SerialName("current_qn") val currentQn: Int, @SerialName("accept_qn") val acceptQn: List = emptyList(), @SerialName("base_url") val baseUrl: String, @SerialName("url_info") val urlInfo: List = emptyList() ) @Serializable data class LiveUrlInfo( val host: String, val extra: String ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/login/Captcha.kt ================================================ package dev.aaa1115910.biliapi.entity.login /** * 人机数据 * * @param token 登录 API token * @param gt 极验id 一般为固定值 * @param challenge 极验KEY 由B站后端产生用于人机验证 */ data class Captcha( val token: String, val challenge: String, val gt: String ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/login/QR.kt ================================================ package dev.aaa1115910.biliapi.entity.login import java.util.Date /** * 获取扫码登录的二维码 * * @param url 二维码内容 * @param key 用于查询扫码登录结果 */ data class QrLoginData( val url: String, val key: String ) /** * 扫码登录结果 * * @param state 登录结果状态 * @param cookies 登录成功的 cookies */ data class QrLoginResult( val state: QrLoginState, val accessToken: String? = null, val refreshToken: String? = null, val cookies: WebCookies? = null ) enum class QrLoginState { Ready, RequestingQRCode, WaitingForScan, WaitingForConfirm, Expired, Success, Error, Unknown } data class WebCookies( val dedeUserId: Long, val dedeUserIdCkMd5: String, val sid: String, val biliJct: String, val sessData: String, val expiredDate: Date ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/login/Sms.kt ================================================ package dev.aaa1115910.biliapi.entity.login import dev.aaa1115910.biliapi.http.entity.login.sms.SmsLoginResponse import java.util.Date data class SmsLoginResult( val status: Int, val message: String, val accessToken: String, val refreshToken: String, val sessData: String, val biliJct: String, val dedeUserId: Long, val dedeUserIdCkMd5: String, val sid: String, val expiredDate: Date ) { companion object { fun fromSmsLoginResponse(smsLoginResponse: SmsLoginResponse) = SmsLoginResult( status = smsLoginResponse.status, message = smsLoginResponse.message, accessToken = smsLoginResponse.tokenInfo!!.accessToken, refreshToken = smsLoginResponse.tokenInfo.refreshToken, sessData = smsLoginResponse.cookieInfo!!.cookies.find { it.name == "SESSDATA" }?.value ?: "", biliJct = smsLoginResponse.cookieInfo.cookies.find { it.name == "bili_jct" }?.value ?: "", dedeUserId = smsLoginResponse.cookieInfo.cookies.find { it.name == "DedeUserID" }?.value?.toLongOrNull() ?: 0, dedeUserIdCkMd5 = smsLoginResponse.cookieInfo.cookies.find { it.name == "DedeUserID__ckMd5" }?.value ?: "", sid = smsLoginResponse.cookieInfo.cookies.find { it.name == "sid" }?.value ?: "", expiredDate = Date(System.currentTimeMillis() + smsLoginResponse.tokenInfo.expiresIn * 1000L) ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/pgc/PgcFeedData.kt ================================================ package dev.aaa1115910.biliapi.entity.pgc data class PgcFeedData( var hasNext: Boolean, var cursor: Int, var items: List = emptyList(), var ranks: List = emptyList() ) { companion object { fun fromPgcFeedData(data: dev.aaa1115910.biliapi.http.entity.pgc.PgcFeedData): PgcFeedData { return PgcFeedData( hasNext = data.hasNext, cursor = data.coursor, items = data.items.map { PgcItem.fromFeedSubItem(it) }, ranks = emptyList() ) } fun fromPgcFeedData(data: dev.aaa1115910.biliapi.http.entity.pgc.PgcFeedV3Data): PgcFeedData { val itemsList = data.items.find { it.subItems.first().cardStyle == "v_card" } val ranksList = data.items.find { it.subItems.first().cardStyle == "rank" } return PgcFeedData( hasNext = data.hasNext, cursor = data.coursor, items = itemsList?.subItems?.map { PgcItem.fromFeedSubItem(it) } ?: emptyList(), ranks = ranksList?.subItems?.map { FeedRank.fromFeedSubItem(it) } ?: emptyList() ) } } data class FeedRank( var cover: String, var title: String, var subTitle: String, var items: List ) { companion object { fun fromFeedSubItem(feedSubItem: dev.aaa1115910.biliapi.http.entity.pgc.PgcFeedV3Data.FeedItem.FeedSubItem): FeedRank { return FeedRank( cover = feedSubItem.cover, title = feedSubItem.title, subTitle = feedSubItem.subTitle, items = feedSubItem.subItems?.map { PgcItem.fromFeedSubItem(it) } ?: emptyList() ) } } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/pgc/PgcItem.kt ================================================ package dev.aaa1115910.biliapi.entity.pgc import dev.aaa1115910.biliapi.http.SeasonIndexType data class PgcItem( var cover: String, var title: String, var subTitle: String, var seasonId: Int, var episodeId: Int, var seasonType: SeasonIndexType, var rating: String ) { companion object { fun fromFeedSubItem(feedSubItem: dev.aaa1115910.biliapi.http.entity.pgc.PgcFeedData.FeedSubItem): PgcItem { return PgcItem( cover = feedSubItem.cover, title = feedSubItem.title, subTitle = feedSubItem.subTitle, seasonId = feedSubItem.seasonId!!, episodeId = feedSubItem.episodeId, seasonType = SeasonIndexType.fromId(feedSubItem.seasonType!!), rating = feedSubItem.rating ?: "0" ) } fun fromFeedSubItem(feedSubItem: dev.aaa1115910.biliapi.http.entity.pgc.PgcFeedV3Data.FeedItem.FeedSubItem): PgcItem { return PgcItem( cover = feedSubItem.cover, title = feedSubItem.title, subTitle = feedSubItem.subTitle, seasonId = feedSubItem.seasonId!!, episodeId = feedSubItem.episodeId ?: feedSubItem.inline!!.epId, seasonType = SeasonIndexType.fromId(feedSubItem.seasonType!!), rating = feedSubItem.rating ?: "0" ) } fun fromIndexResultItem(indexResultItem: dev.aaa1115910.biliapi.http.entity.index.IndexResultData.IndexResultItem): PgcItem { return PgcItem( cover = indexResultItem.cover, title = indexResultItem.title, subTitle = indexResultItem.subTitle, seasonId = indexResultItem.seasonId, episodeId = indexResultItem.firstEp.epId, seasonType = SeasonIndexType.fromId(indexResultItem.seasonType), rating = indexResultItem.score ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/pgc/PgcType.kt ================================================ package dev.aaa1115910.biliapi.entity.pgc enum class PgcType { Anime, GuoChuang, Movie, Documentary, Tv, Variety } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/pgc/index/IndexParams.kt ================================================ package dev.aaa1115910.biliapi.entity.pgc.index import dev.aaa1115910.biliapi.entity.pgc.PgcType interface PgcIndexParam /** * 排序 */ enum class IndexOrder(val id: Int) : PgcIndexParam { UpdateTime(0), // 更新时间 DanmakuCount(1), // 弹幕数量 PlayCount(2), // 播放数量 FollowCount(3), // 追番人数 Score(4), // 最高评分 StartTime(5), // 开播时间 PublishTime(6); // 上映时间 companion object { fun getList(pgcType: PgcType): List = when (pgcType) { PgcType.Anime -> listOf(FollowCount, UpdateTime, Score, PlayCount, StartTime) PgcType.GuoChuang -> listOf(FollowCount, UpdateTime, Score, PlayCount, StartTime) PgcType.Movie -> listOf(PlayCount, UpdateTime, PublishTime, Score) PgcType.Documentary -> listOf(PlayCount, Score, UpdateTime, PublishTime, DanmakuCount) PgcType.Tv -> listOf(PlayCount, UpdateTime, DanmakuCount, Score, FollowCount) PgcType.Variety -> listOf(PlayCount, UpdateTime, PublishTime, Score, DanmakuCount) } } } enum class IndexOrderType(val id: Int) : PgcIndexParam { Desc(0), // 降序 Asc(1); // 升序 } /** * 类型 */ enum class SeasonVersion(val id: Int) : PgcIndexParam { All(-1), // 全部 FeatureFilm(1), // 正片 Movies(2), // 电影 Other(3); // 其他 companion object { fun getList(pgcType: PgcType): List { return when (pgcType) { PgcType.Anime, PgcType.GuoChuang -> listOf(All, FeatureFilm, Movies, Other) else -> emptyList() } } } } /** * 配音 */ enum class SpokenLanguage(val id: Int) : PgcIndexParam { All(-1), // 全部 OriginalSoundtrack(1), // 原声 ChineseDubbing(2); // 中文配音 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime -> listOf(All, OriginalSoundtrack, ChineseDubbing) else -> emptyList() } } } /** * 地区 */ enum class Area(val id: Int) : PgcIndexParam { All(-1), // 全部 MainlandChina(1), // 中国大陆 Japan(2), // 日本 America(3), // 美国 Britain(4), // 英国 Other(5), // 其他 ChinaHongKongTaiwan(6), // 中国港台 6,7 Korea(8), // 韩国 France(9), // 法国 Thailand(10), // 泰国 Spain(13), // 西班牙 Germany(15), // 德国 Italy(35); // 意大利 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime -> listOf(All, Japan, America, Other) PgcType.Movie -> listOf( All, MainlandChina, ChinaHongKongTaiwan, America, Japan, Korea, France, Britain, Germany, Thailand, Italy, Spain, Other ) PgcType.Tv -> listOf(All, MainlandChina, Japan, America, Britain, Other) else -> emptyList() } } } /** * 状态(完结状态) */ enum class IsFinish(val id: Int) : PgcIndexParam { All(-1), // 全部 Finished(1), // 完结 Serialization(0); // 连载 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime, PgcType.GuoChuang -> listOf(All, Finished, Serialization) else -> emptyList() } } } /** * 版权 */ enum class Copyright(val id: Int) : PgcIndexParam { All(-1), // 全部 Exclusive(3), // 独家 Other(1); // 其他 1,2,4 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime, PgcType.GuoChuang -> listOf(All, Exclusive, Other) else -> emptyList() } } } /** * 付费(付费状态) */ enum class SeasonStatus(val id: Int) : PgcIndexParam { All(-1), // 全部 Free(1), // 免费 Paid(2), // 付费 2,6 Prime(4); // 大会员 4,6 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime, PgcType.GuoChuang, PgcType.Movie -> listOf(All, Free, Paid, Prime) PgcType.Documentary, PgcType.Tv, PgcType.Variety -> listOf(All, Free, Prime) } } } /** * 季度 */ enum class SeasonMonth(val id: Int) : PgcIndexParam { All(-1), // 全部 January(1), // 1月 April(4), // 4月 July(7), // 7月 October(10); // 10月 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime -> listOf(All, January, April, July, October) else -> emptyList() } } } /** * 出品(方) */ enum class Producer(val id: Int) : PgcIndexParam { All(-1), // 全部 BBC(1), // BBC NHK(2), // NHK SKY(3), // SKY CCTV(4), // 央视 ITV(5), // ITV HistoryChannel(6), // 历史频道 DiscoveryChannel(7),// 探索频道 SatelliteTV(8), // 卫视 SelfMade(9), // 自制 ZDF(10), // ZDF Cooperation(11), // 合作机构 DomesticOther(12), // 国内其他 ForeignOther(13), // 国外其他 NationalGeographic(14), // 国家地理 Sony(15), // 索尼 Universal(16), // 环球 Paramount(17), // 派拉蒙 Warner(18), // 华纳 Disney(19), // 迪士尼 HBO(20); // HBO companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Documentary -> listOf( All, CCTV, BBC, DiscoveryChannel, NationalGeographic, NHK, HistoryChannel, SatelliteTV, SelfMade, ITV, SKY, ZDF, Cooperation, DomesticOther, ForeignOther, Sony, Universal, Paramount, Warner, Disney, HBO ) else -> emptyList() } } } /** * 年份(Year) */ enum class Year(val str: String) : PgcIndexParam { All("-1"), // 全部 Year2026("[2026,2027)"), // 2026 Year2025("[2025,2026)"), // 2025 Year2024("[2024,2025)"), // 2024 Year2023("[2023,2024)"), // 2023 Year2022("[2022,2023)"), // 2022 Year2021("[2021,2022)"), // 2021 Year2020("[2020,2021)"), // 2020 Year2019("[2019,2020)"), // 2019 Year2018("[2018,2019)"), // 2018 Year2017("[2017,2018)"), // 2017 Year2016("[2016,2017)"), // 2016 Year2015("[2015,2016)"), // 2015 Year2014_2010("[2010,2015)"), // 2014-2010 Year2009_2005("[2005,2010)"), // 2009-2005 Year2004_2000("[2000,2005)"), // 2004-2000 Year199x("[1990,2000)"), // 90年代 Year198x("[1980,1990)"), // 80年代 Earlier("[,1980)"); // 更早 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime, PgcType.GuoChuang -> listOf( All, Year2026, Year2025, Year2024, Year2023, Year2022, Year2021, Year2020, Year2019, Year2018, Year2017, Year2016, Year2015, Year2014_2010, Year2009_2005, Year2004_2000, Year199x, Year198x, Earlier ) else -> emptyList() } } } /** * 年份(发布时间) */ enum class ReleaseDate(val str: String) : PgcIndexParam { All("-1"), // 全部 Year2026("[2026-01-01 00:00:00,2027-01-01 00:00:00)"), // 2026 Year2025("[2025-01-01 00:00:00,2026-01-01 00:00:00)"), // 2025 Year2024("[2024-01-01 00:00:00,2025-01-01 00:00:00)"), // 2024 Year2023("[2023-01-01 00:00:00,2024-01-01 00:00:00)"), // 2023 Year2022("[2022-01-01 00:00:00,2023-01-01 00:00:00)"), // 2022 Year2021("[2021-01-01 00:00:00,2022-01-01 00:00:00)"), // 2021 Year2020("[2020-01-01 00:00:00,2021-01-01 00:00:00)"), // 2020 Year2019("[2019-01-01 00:00:00,2020-01-01 00:00:00)"), // 2019 Year2018("[2018-01-01 00:00:00,2019-01-01 00:00:00)"), // 2018 Year2017("[2017-01-01 00:00:00,2018-01-01 00:00:00)"), // 2017 Year2016("[2016-01-01 00:00:00,2017-01-01 00:00:00)"), // 2016 Year2015_2010("[2010-01-01 00:00:00,2015-01-01 00:00:00)"), // 2015-2010 Year2009_2005("[2005-01-01 00:00:00,2010-01-01 00:00:00)"), // 2009-2005 Year2004_2000("[2000-01-01 00:00:00,2005-01-01 00:00:00)"), // 2004-2000 Year199x("[1990-01-01 00:00:00,2000-01-01 00:00:00)"), // 90年代 Year198x("[1980-01-01 00:00:00,1990-01-01 00:00:00)"), // 80年代 Earlier("[,1980-01-01 00:00:00)"); // 更早 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Movie, PgcType.Documentary, PgcType.Tv -> listOf( All, Year2026, Year2025, Year2024, Year2023, Year2022, Year2021, Year2020, Year2019, Year2018, Year2017, Year2016, Year2015_2010, Year2009_2005, Year2004_2000, Year199x, Year198x, Earlier ) else -> emptyList() } } } /** * 风格 */ enum class Style(val id: Int) : PgcIndexParam { All(-1), // 全部 Movie(-10), // 电影 Original(10010), // 原创 Comic(10011), // 漫画改 Novel(10012), // 小说改 Game(10013), // 游戏改 Animation(10014), // 动态漫 Puppetry(10015), // 布袋戏 HotBlood(10016), // 热血 TimeTravel(10017), // 穿越 Fantasy(10018), // 奇幻 XuanHuan(10019), // 玄幻 Fight(10020), // 战斗 Funny(10021), // 搞笑 Daily(10022), // 日常 ScienceFiction(10023), // 科幻 Moe(10024), // 萌系 Healing(10025), // 治愈 School(10026), // 校园 Children(10027), // 少儿 InstantNoodles(10028), // 泡面 InLove(10029), // 恋爱 Girl(10030), // 少女 Magic(10031), // 魔法 Adventure(10032), // 冒险 History(10033), // 历史 Fiction(10034), // 架空 Mecha(10035), // 机战 GodDemon(10036), // 神魔 VoiceControl(10037),// 声控 Sports(10038), // 运动 Inspirational(10039), // 励志 Music(10040), // 音乐 Reasoning(10041), // 推理 Club(10042), // 社团 WisdomFight(10043), // 智斗 Tearjerker(10044), // 催泪 Food(10045), // 美食 Idol(10046), // 偶像 Maiden(10047), // 乙女 Workplace(10048), // 职场 AncientStyle(10049),// 古风 Plot(10050), // 剧情 Comedy(10051), // 喜剧 Love(10052), // 爱情 Action(10053), // 动作 Terror(10054), // 恐怖 Offense(10055), // 犯罪 Thriller(10056), // 惊悚 Suspense(10057), // 悬疑 War(10058), // 战争 // 10059 Biography(10060), // 传记 Family(10061), // 家庭 Opera(10062), // 歌剧 Documentary(10063), // 纪实 Disaster(10064), // 灾难 Humanities(10065), // 人文 Technology(10066), // 科技 Explore(10067), // 探险 Universal(10068), // 通用 CutePet(10069), // 萌宠 Social(10070), // 社会 Animal(10071), // 动物 Nature(10072), // 自然 Medical(10073), // 医疗 Military(10074), // 军事 Crime(10075), // 罪案 Mystery(10076), // 神秘 Travel(10077), // 旅行 MartialArts(10078), // 武侠 Youth(10079), // 青春 City(10080), // 都市 AncientCostume(10081), // 古装 SpyWar(10082), // 谍战 Classic(10083), // 经典 Emotion(10084), // 情感 Myth(10085), // 神话 Age(10086), // 年代 Rural(10087), // 农村 CriminalInvestigation(10088), // 刑侦 MilitaryLife(10089),// 军旅 Interview(10090), // 访谈 TalkShow(10091), // 脱口秀 RealityShow(10092), // 真人秀 //10093 Selection(10094), // 选秀 Tourism(10095), // 旅游 Concert(10096), // 演唱会 ParentChild(10097), // 亲子 EveningParty(10098),// 晚会 Cultivate(10099), // 养成 Culture(10100), // 文化 //10101 SpecialEffects(10102), // 特摄 ShortPlay(10103), // 短剧 ShortFilm(10104); // 短片 companion object { fun getList(pgcType: PgcType) = when (pgcType) { PgcType.Anime -> listOf( All, Original, Comic, Novel, Game, SpecialEffects, Puppetry, HotBlood, TimeTravel, Fantasy, Fight, Funny, Daily, ScienceFiction, Moe, Healing, School, Children, InstantNoodles, InLove, Girl, Magic, Adventure, History, Fiction, Mecha, GodDemon, VoiceControl, Sports, Inspirational, Music, Reasoning, Club, WisdomFight, Tearjerker, Food, Idol, Maiden, Workplace ) PgcType.GuoChuang -> listOf( All, Original, Comic, Novel, Game, Animation, Puppetry, HotBlood, Fantasy, XuanHuan, Fight, Funny, MartialArts, Daily, ScienceFiction, Moe, Healing, Suspense, School, Children, InstantNoodles, InLove, Girl, Magic, History, Mecha, GodDemon, VoiceControl, Sports, Inspirational, Music, Reasoning, Club, WisdomFight, Tearjerker, Food, Idol, Maiden, Workplace, AncientStyle ) PgcType.Movie -> listOf( All, ShortFilm, Plot, Comedy, Love, Action, Terror, ScienceFiction, Offense, Thriller, Suspense, Fantasy, War, Animation, Biography, Family, Opera, History, Adventure, Documentary, Disaster, Comic, Novel ) PgcType.Documentary -> listOf( All, History, Food, Humanities, Technology, Explore, Universal, CutePet, Social, Animal, Nature, Medical, Military, Disaster, Crime, Mystery, Travel, Sports, Movie ) PgcType.Variety -> listOf( All, Music, Interview, TalkShow, RealityShow, Selection, Food, Tourism, EveningParty, Concert, Emotion, Comedy, ParentChild, Culture, Workplace, CutePet, Cultivate ) PgcType.Tv -> listOf( All, Plot, Emotion, Funny, Suspense, City, Family, AncientCostume, History, Fantasy, Youth, War, MartialArts, Inspirational, ShortPlay, ScienceFiction ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/pgc/index/PgcIndexCondition.kt ================================================ package dev.aaa1115910.biliapi.entity.pgc.index import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable const val PGC_INDEX_ORDER_FIELD = "order" @Serializable data class PgcIndexConditionData( @SerialName("filter") val filters: List = emptyList(), val order: List = emptyList() ) { fun buildSections(): List = buildList { if (order.isNotEmpty()) { add( PgcIndexSection( field = PGC_INDEX_ORDER_FIELD, title = "排序", options = order.map { PgcIndexOption( field = PGC_INDEX_ORDER_FIELD, keyword = it.field, name = it.name, sort = it.sort.substringBefore(',').ifBlank { "0" } ) } ) ) } filters.forEach { filter -> if (filter.values.isNotEmpty()) { add( PgcIndexSection( field = filter.field, title = filter.name, options = filter.values.map { value -> PgcIndexOption( field = filter.field, keyword = value.keyword, name = value.name ) } ) ) } } } } @Serializable data class PgcIndexConditionFilter( val field: String, val name: String, val values: List = emptyList() ) @Serializable data class PgcIndexConditionOrder( val field: String, val name: String, val sort: String = "0" ) @Serializable data class PgcIndexConditionValue( val keyword: String, val name: String ) data class PgcIndexSection( val field: String, val title: String, val options: List ) data class PgcIndexOption( val field: String, val keyword: String, val name: String, val sort: String? = null ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/pgc/index/PgcIndexData.kt ================================================ package dev.aaa1115910.biliapi.entity.pgc.index import dev.aaa1115910.biliapi.entity.pgc.PgcItem data class PgcIndexData( val list: List, val nextPage: PgcIndexPage ) { companion object { fun fromIndexResultData(data: dev.aaa1115910.biliapi.http.entity.index.IndexResultData): PgcIndexData { return PgcIndexData( list = data.list.map { PgcItem.fromIndexResultItem(it) }, nextPage = PgcIndexPage( currentPage = data.num, pageSize = data.size, totalSize = data.total, nextPage = data.num + 1, hasNext = data.hasNext == 1 ) ) } } data class PgcIndexPage( val currentPage: Int = 1, val pageSize: Int = 20, val totalSize: Int = 0, val nextPage: Int = 1, val hasNext: Boolean = true ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/rank/Popular.kt ================================================ package dev.aaa1115910.biliapi.entity.rank import dev.aaa1115910.biliapi.entity.ugc.UgcItem data class PopularVideoData( val list: List, val nextPage: PopularVideoPage, val noMore: Boolean ) data class PopularVideoPage( val nextWebPageSize: Int = 20, val nextWebPageNumber: Int = 1, val nextAppIndex: Int = 0, ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/reply/Comment.kt ================================================ package dev.aaa1115910.biliapi.entity.reply import dev.aaa1115910.biliapi.entity.Picture data class CommentsData( val comments: List = emptyList(), val nextPage: CommentPage = CommentPage(), val hasNext: Boolean ) { companion object { fun fromCommentData(commentData: dev.aaa1115910.biliapi.http.entity.reply.CommentData): CommentsData { val nextOffset = commentData.cursor.paginationReply?.nextOffset return CommentsData( comments = commentData.replies.map { Comment.fromReply(it) }, nextPage = CommentPage( nextWebPage = commentData.cursor.paginationReply?.nextOffset ?: "" ), hasNext = commentData.cursor.isEnd.not() && nextOffset != null ) } fun fromMainListReply(mainListReply: bilibili.main.community.reply.v1.MainListReply): CommentsData { return CommentsData( comments = mainListReply.repliesList.map { Comment.fromReplyInfo(it) }, nextPage = CommentPage( nextAppPage = mainListReply.paginationReply.nextOffset ), hasNext = mainListReply.cursor.isEnd.not() ) } } } data class Comment( val rpid: Long, val mid: Long, val oid: Long, val type: Long, val parent: Long, val content: List, val member: Member, val timeDesc: String, val emotes: List, val pictures: List, val replies: List, val repliesCount: Int, val like: Long = 0, ) { companion object { fun fromReply(reply: dev.aaa1115910.biliapi.http.entity.reply.CommentData.Reply): Comment { return Comment( rpid = reply.rpid, mid = reply.mid, oid = reply.oid, type = reply.type, parent = reply.parent, content = reply.content.message.splitWithEmotes(*reply.content.emote.keys.toTypedArray()), member = Member( mid = reply.mid, avatar = reply.member.avatar, name = reply.member.uname ), timeDesc = reply.replyControl.timeDesc, emotes = reply.content.emote.values.map { Emote.fromEmote(it) }, pictures = reply.content.pictures.map { Picture.fromPicture(it) }, replies = reply.replies.map { fromReply(it) }, repliesCount = reply.rcount, like = reply.like.toLong() ) } fun fromReplyInfo(reply: bilibili.main.community.reply.v1.ReplyInfo): Comment { return Comment( rpid = reply.id, mid = reply.mid, oid = reply.oid, type = reply.type, parent = reply.parent, content = reply.content.message.splitWithEmotes(*reply.content.emoteMap.keys.toTypedArray()), member = Member( mid = reply.mid, avatar = reply.member.face, name = reply.member.name ), timeDesc = reply.replyControl.timeDesc, emotes = reply.content.emoteMap.values.map { Emote.fromEmote(it) }, pictures = reply.content.picturesList.map { Picture.fromPicture(it) }, replies = reply.repliesList.map { fromReplyInfo(it) }, repliesCount = reply.count.toInt(), like = reply.like ) } } data class Member( val mid: Long, val avatar: String, val name: String ) data class Emote( val text: String, val url: String, val size: EmoteSize ) { companion object { fun fromEmote(emote: dev.aaa1115910.biliapi.http.entity.reply.CommentData.Reply.Content.Emote): Emote { return Emote( text = emote.text, url = emote.url, size = if (emote.meta.size == 1) EmoteSize.Small else EmoteSize.Large ) } fun fromEmote(emote: bilibili.main.community.reply.v1.Emote): Emote { return Emote( text = emote.text, url = emote.url, size = if (emote.size == 1L) EmoteSize.Small else EmoteSize.Large ) } } } } enum class EmoteSize(val fontSize: Int) { Small(20), Large(20) } private fun String.splitWithEmotes(vararg emotes: String): List { val delimiter = emotes.joinToString("|").replace("[", "\\[").replace("]", "\\]") val regex = Regex("(?=$delimiter)|(?<=$delimiter)") return this.split(regex) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/reply/CommentPage.kt ================================================ package dev.aaa1115910.biliapi.entity.reply data class CommentPage( val nextWebPage: String = "", val nextAppPage: String = "" ) data class CommentReplyPage( val nextWebPage: Int = 1, val nextAppPage: String = "" ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/reply/CommentRepliesData.kt ================================================ package dev.aaa1115910.biliapi.entity.reply data class CommentRepliesData( val rootComment: Comment, val replies: List, val nextPage: CommentReplyPage = CommentReplyPage(), val hasNext: Boolean ) { companion object { fun fromCommentReplyData(commentReplyData: dev.aaa1115910.biliapi.http.entity.reply.CommentReplyData): CommentRepliesData { val nextOffset = commentReplyData.page.num * commentReplyData.page.size return CommentRepliesData( rootComment = Comment.fromReply(commentReplyData.root), replies = commentReplyData.replies.map { Comment.fromReply(it) }, nextPage = CommentReplyPage( nextWebPage = commentReplyData.page.num + 1 ), hasNext = commentReplyData.page.count > nextOffset ) } fun fromCommentReplyList(detailListReply: bilibili.main.community.reply.v1.DetailListReply): CommentRepliesData { return CommentRepliesData( rootComment = Comment.fromReplyInfo(detailListReply.root), replies = detailListReply.root.repliesList.map { Comment.fromReplyInfo(it) }, nextPage = CommentReplyPage( nextAppPage = detailListReply.paginationReply.nextOffset ), hasNext = detailListReply.cursor.isEnd.not() ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/reply/CommentSort.kt ================================================ package dev.aaa1115910.biliapi.entity.reply enum class CommentSort(val param: Int) { Hot(3), HotAndTime(1), Time(2) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/search/Hotword.kt ================================================ package dev.aaa1115910.biliapi.entity.search data class Hotword( val keyword: String, val showName: String, val icon: String?, ) { companion object { fun fromHttpWebHotword(hotword: dev.aaa1115910.biliapi.http.entity.search.Hotword) = Hotword( keyword = hotword.keyword, showName = hotword.showName, icon = hotword.icon ) fun fromHttpAppSquareDataItem(squareDataItem: dev.aaa1115910.biliapi.http.entity.search.AppSearchSquareData.SquareData.SquareDataItem) = Hotword( keyword = squareDataItem.keyword ?: "", showName = squareDataItem.showName ?: "", icon = squareDataItem.icon ) fun fromHttpAppSearchTrendingHotword(hotword: dev.aaa1115910.biliapi.http.entity.search.SearchTendingData.Hotword) = Hotword( keyword = hotword.keyword, showName = hotword.showName, icon = hotword.icon ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/season/FollowingSeasons.kt ================================================ package dev.aaa1115910.biliapi.entity.season data class FollowingSeasonData( val list: List, val total: Int ) data class FollowingSeason( val seasonId: Int, val title: String, val cover: String ) { companion object { fun fromFollowingSeason(season: dev.aaa1115910.biliapi.http.entity.season.WebFollowingSeason) = FollowingSeason( seasonId = season.seasonId, title = season.title, cover = season.cover ) fun fromFollowingSeason(season: dev.aaa1115910.biliapi.http.entity.season.AppFollowingSeason) = FollowingSeason( seasonId = season.seasonId, title = season.title, cover = season.cover ) } } enum class FollowingSeasonType(val id: Int, val paramName: String) { Bangumi(id = 1, paramName = "bangumi"), Cinema(id = 2, paramName = "cinema") } enum class FollowingSeasonStatus(val id: Int) { All(id = 0), Want(id = 1), Watching(id = 2), Watched(id = 3) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/season/IndexResult.kt ================================================ package dev.aaa1115910.biliapi.entity.season data class IndexResultData( val list: List, val nextPage: IndexResultPage ) { companion object { fun fromIndexResultData(data: dev.aaa1115910.biliapi.http.entity.index.IndexResultData) = IndexResultData( list = data.list.map { IndexResultItem.fromIndexResultItem(it) }, nextPage = IndexResultPage( nextPage = (data.num + 1).takeIf { data.hasNext == 1 } ?: -1, hasNext = data.hasNext == 1 ) ) } } data class IndexResultPage( val nextPage: Int = 1, val hasNext: Boolean = true ) data class IndexResultItem( val title: String, val subTitle: String, val cover: String, val score: String, val badge: Badge?, val indexShow: String, val seasonId: Int ) { companion object { fun fromIndexResultItem(item: dev.aaa1115910.biliapi.http.entity.index.IndexResultData.IndexResultItem): IndexResultItem { return IndexResultItem( title = item.title, subTitle = item.subTitle, cover = item.cover, score = item.score, badge = Badge( text = item.badgeInfo.text, bgColor = item.badgeInfo.bgColor, bgColorNight = item.badgeInfo.bgColorNight ).takeIf { item.badgeInfo.text.isNotEmpty() }, indexShow = item.indexShow, seasonId = item.seasonId ) } } data class Badge( val text: String, val bgColor: String, val bgColorNight: String ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/season/Timeline.kt ================================================ package dev.aaa1115910.biliapi.entity.season import java.util.Date enum class TimelineFilter( val webFilterId: Int, val appFilterId: Int ) { All(webFilterId = -1, appFilterId = 0), Anime(webFilterId = 1, appFilterId = 1), Following(webFilterId = -1, appFilterId = 2), GuoChuang(webFilterId = 4, appFilterId = 3); companion object { val webFilters = listOf(Anime, GuoChuang) val appFilters = listOf(All, Anime, Following, GuoChuang) } } data class Timeline( val dateString: String, val date: Date, val dayOfWeek: Int, val isToday: Boolean, val episodes: List ) { companion object { fun fromTimeline(timeline: dev.aaa1115910.biliapi.http.entity.video.Timeline) = Timeline( dateString = timeline.date, date = Date(timeline.dateTs * 1000L), dayOfWeek = timeline.dayOfWeek, isToday = timeline.isToday, episodes = timeline.episodes.map { TimelineEp.fromTimelineEpisode(it) } ) } } data class TimelineEp( val cover: String, val title: String, val seasonId: Int, val publishIndex: String, val publishTime: String, val publishDate: Date ) { companion object { fun fromTimelineEpisode(episode: dev.aaa1115910.biliapi.http.entity.video.Timeline.Episode) = TimelineEp( cover = episode.cover, title = episode.title, seasonId = episode.seasonId, publishIndex = episode.pubIndex, publishTime = episode.pubTime, publishDate = Date(episode.pubTs * 1000L) ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/UgcItem.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc import dev.aaa1115910.biliapi.http.entity.home.RcmdIndexData import dev.aaa1115910.biliapi.http.entity.home.RcmdTopData import dev.aaa1115910.biliapi.util.convertStringTimeToSeconds import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import java.util.TimeZone data class UgcItem( val aid: Long, val bvid: String = "", val title: String, val cover: String, val author: String, val authorId: Long = 0, val authorFace: String = "", val play: Long, val danmaku: Int, val duration: Int, val idx: Int = -1, val pubTime: String? = null, ) { companion object { fun fromRcmdItem(rcmdItem: RcmdIndexData.RcmdItem) = UgcItem( aid = rcmdItem.args.aid ?: 0, title = rcmdItem.title!!, cover = rcmdItem.cover!!, author = rcmdItem.args.upName ?: "", authorId = rcmdItem.args.upId ?: 0, // authorFace = rcmdItem.args.upFace ?: "", play = with(rcmdItem.coverLeftText1) { runCatching { if (this!!.endsWith("万")) { (this.substring(0, this.length - 1).toDouble() * 10000).toLong() } else { this.toLong() } }.getOrDefault(-1) }, danmaku = with(rcmdItem.coverLeftText2) { if (this == null) return@with -1 runCatching { if (this.endsWith("万")) { (this.substring(0, this.length - 1).toDouble() * 10000).toInt() } else { this.toInt() } }.getOrDefault(-1) }, duration = rcmdItem.coverRightText?.convertStringTimeToSeconds() ?: 0, idx = rcmdItem.idx ) fun fromRcmdItem(rcmdItem: RcmdTopData.RcmdItem) = UgcItem( aid = rcmdItem.id, bvid = rcmdItem.bvid, title = rcmdItem.title, cover = rcmdItem.pic, author = rcmdItem.owner?.name ?: "", authorId = rcmdItem.owner?.mid ?: 0, authorFace = rcmdItem.owner?.face ?: "", play = rcmdItem.stat?.view ?: -1L, danmaku = rcmdItem.stat?.danmaku ?: -1, duration = rcmdItem.duration, pubTime = rcmdItem.pubdate.smartDate ) fun fromVideoInfo(videoInfo: dev.aaa1115910.biliapi.http.entity.video.VideoInfo) = UgcItem( aid = videoInfo.aid, title = videoInfo.title, duration = videoInfo.duration, author = videoInfo.owner.name, authorId = videoInfo.owner.mid, authorFace = videoInfo.owner.face, cover = videoInfo.pic, play = videoInfo.stat.view, danmaku = videoInfo.stat.danmaku, pubTime = videoInfo.pubdate.smartDate ) fun fromSmallCoverV5(card: bilibili.app.card.v1.SmallCoverV5): UgcItem { // 格式:"n.n万观看 · n天前" val playAndPubTime = card.rightDesc2.split(" · ") val play = playAndPubTime.getOrNull(0)?.let { convertPlayStringToLong(it) } ?: -1 val pubTime = playAndPubTime.getOrNull(1) return UgcItem( aid = card.base.param.toLong(), title = card.base.title, duration = convertStringTimeToSeconds(card.coverRightText1), author = card.rightDesc1, // authorId = card.base.upId, // authorFace = card.base.upFace, cover = card.base.cover, play = play, pubTime = pubTime, danmaku = -1, idx = card.base.idx.toInt() ) } fun fromRegionDynamicListItem(item: dev.aaa1115910.biliapi.http.entity.region.RegionDynamicList.Item) = UgcItem( aid = item.param.toLong(), title = item.title, duration = item.duration, author = item.name, // authorId = item.mid, authorFace = item.face, cover = item.cover, play = item.play ?: -1, danmaku = item.danmaku ?: -1, pubTime = item.pubDate.smartDate ) fun fromRegionRcmdArchive(archive: dev.aaa1115910.biliapi.http.entity.region.RegionFeedRcmd.Archive) = UgcItem( aid = archive.aid, title = archive.title, duration = archive.duration, author = archive.author.name, authorId = archive.author.mid, cover = archive.cover, play = archive.stat.view, danmaku = archive.stat.danmaku, pubTime = archive.pubdate.toSmartDate() ) } } private fun convertPlayStringToLong(text: String): Long { if (text.isBlank()) return -1 val value = text.replace("观看", "").trim() return try { when { value.endsWith("万") -> { val num = value.removeSuffix("万").toDouble() (num * 10_000).toLong() } value.endsWith("亿") -> { val num = value.removeSuffix("亿").toDouble() (num * 100_000_000).toLong() } else -> { value.toLong() } } } catch (e: Exception) { -1 } } private fun convertStringTimeToSeconds(time: String): Int { val parts = time.split(":") val hours = if (parts.size == 3) parts[0].toInt() else 0 val minutes = parts[parts.size - 2].toInt() val seconds = parts[parts.size - 1].toInt() return (hours * 3600) + (minutes * 60) + seconds } /** * 智能日期格式化 (兼容低版本 Android) * @param timeZone 时区 (默认系统时区) */ fun Long.toSmartDate(timeZone: TimeZone = TimeZone.getDefault()): String? { if (this <= 0) return null try { // 自动识别秒级或毫秒级时间戳 // 秒级时间戳通常小于等于10位数,目前直到2286年都是10位数 // 毫秒级时间戳通常为13位数 val timeInMillis = if (this < 10000000000L) this * 1000L else this // 创建日历实例 val cal = Calendar.getInstance(timeZone).apply { this.timeInMillis = timeInMillis } // 获取当前年份 val currentYear = Calendar.getInstance(timeZone).get(Calendar.YEAR) // 动态格式选择 val pattern = if (cal.get(Calendar.YEAR) == currentYear) { "M-d H:mm" } else { "yyyy-M-d" } // 线程安全的日期格式化 return SimpleDateFormat(pattern, Locale.CHINESE).apply { this.timeZone = timeZone }.format(cal.time) } catch (e: Exception) { return null } } val Int.smartDate: String? get() = this.toLong().toSmartDate() ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/UgcType.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc @Deprecated("Use dev.aaa1115910.biliapi.entity.ugc.UgcTypeV2 instead") enum class UgcType(val rid: Int, val codename: String, val locId: Int = -1) { Douga(1, "douga", 4973), DougaMad(24, "mad"), DougaMmd(25, "mmd"), DougaHandDrawn(47, "handdrawn"), DougaVoice(257, "voice"), DougaGarageKit(210, "garage_kit"), DougaTokusatsu(86, "tokusatsu"), DougaAcgnTalks(253, "acgntalks"), DougaOther(27, "other"), Game(4, "game", 4991), GameStandAlone(17, "stand_alone"), GameESports(171, "esports"), GameMobile(172, "mobile"), GameOnline(65, "online"), GameBoard(173, "board"), GameGmv(121, "gmv"), GameMusic(136, "music"), GameMugen(19, "mugen"), Kichiku(119, "kichiku", 5004), KichikuGuide(22, "guide"), KichikuMad(26, "mad"), KichikuManualVocaloid(126, "manual_vocaloid"), KichikuTheatre(216, "theatre"), KichikuCourse(127, "course"), Music(3, "music", 4979), MusicOriginal(28, "original"), MusicLive(29, "live"), MusicCover(31, "cover"), MusicPerform(31, "perform"), MusicCommentary(243, "commentary"), MusicVocaloidUtau(30, "vocaloid"), MusicMv(193, "mv"), MusicFanVideos(266, "fan_videos"), MusicAiMusic(265, "ai_music"), MusicRadio(267, "radio"), MusicTutorial(244, "tutorial"), MusicOther(130, "other"), Dance(129, "dance", 4985), DanceOtaku(20, "otaku"), DanceHiphop(198, "hiphop"), DanceStar(199, "star"), DanceChina(200, "china"), DanceGestures(255, "gestures"), DanceThreeD(154, "three_d"), DanceDemo(156, "demo"), Cinephile(181, "cinephile", 5008), CinephileCinecism(182, "cinecism"), CinephileNibtage(183, "montage"), CinephileMashup(260, "mashup"), CinephileAiImagine(259, "ai_imaging"), CinephileTrailerInfo(184, "trailer_info"), CinephileShortPlay(85, "shortplay"), CinephileShortFilm(256, "shortfilm"), CinephileComperhensive(261, "comprehensive"), Ent(5, "ent", 5007), EntTalker(241, "talker"), EntCpRecommendation(262, "cp_recommendation"), EntBeauty(263, "beauty"), EntFans(242, "fans"), EntEntertainmentNews(264, "entertainment_news"), EntCelebrity(137, "celebrity"), EntVariety(71, "variety"), Knowledge(36, "knowledge", 4997), KnowledgeScience(201, "science"), KnowledgeSocialScience(124, "social_science"), KnowledgeHumanity(228, "humanity_history"), KnowledgeBusiness(207, "business"), KnowledgeCampus(208, "campus"), KnowledgeCareer(209, "career"), KnowledgeDesign(229, "design"), KnowledgeSkill(122, "skill"), Tech(188, "tech", 4998), TechDigital(95, "digital"), TechApplication(230, "application"), TechComputerTech(231, "computer_tech"), TechIndustry(232, "industry"), TechDiy(233, "diy"), Information(202, "information", 5005), InformationHotspot(203, "hotspot"), InformationGlobal(204, "global"), InformationSocial(205, "social"), InformationMultiple(206, "multiple"), Food(211, "food", 5002), FoodMake(76, "make"), FoodDetective(212, "detective"), FoodMeasurement(213, "measurement"), FoodRural(214, "rural"), FoodRecord(215, "record"), Life(160, "life", 5001), LifeFunny(138, "funny"), LifeParenting(254, "parenting"), LifeTravel(250, "travel"), LiseRuralLife(251, "rurallife"), LifeHome(239, "home"), LifeHandMake(161, "handmake"), LifePainting(162, "painting"), LifeDaily(21, "daily"), Car(223, "car", 5000), CarKnowledge(258, "knowledge"), CarStrategy(227, "strategy"), CarNewEnergyVehicle(247, "newenergyvehicle"), CarRacing(245, "racing"), CarModifiedVehicle(246, "modifiedvehicle"), CarMotorcycle(240, "motorcycle"), CarTouringCar(248, "touringcar"), CarLife(176, "life"), Fashion(155, "fashion", 5006), FashionMakeup(157, "makeup"), FashionCos(252, "cos"), FashionClothing(158, "clothing"), FashionCatwalk(159, "catwalk"), Sports(234, "sports", 4999), SportsBasketball(235, "basketball"), SportsFootball(249, "football"), SportsAerobics(164, "aerobics"), SportsAthletic(236, "athletic"), SportsCulture(237, "culture"), SportsComprehensive(238, "comprehensive"), Animal(217, "animal", 5003), AnimalCat(218, "cat"), AnimalDog(291, "dog"), AnimalReptiles(222, "reptiles"), AnimalWildAnima(221, "wild_animal"), AnimalSecondEdition(220, "second_edition"), AnimalComposite(75, "animal_composite") } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/UgcTypeV2.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc enum class UgcTypeV2(val tid: Int, val codename: String, val channelId: Int? = null) { // 动画 Douga(1005, "douga", 7), DougaFanAnime(2037, "fan_anime"), DougaGarageKit(2038, "garage_kit"), DougaCosplay(2039, "cosplay"), DougaOffline(2040, "offline"), DougaEditing(2041, "editing"), DougaCommentary(2042, "commentary"), DougaQuickView(2043, "quick_view"), DougaVoice(2044, "voice"), DougaInformation(2045, "information"), DougaInterpret(2046, "interpret"), DougaVup(2047, "vup"), DougaTokusatsu(2048, "tokusatsu"), DougaPuppetry(2049, "puppetry"), DougaComic(2050, "comic"), DougaMotion(2051, "motion"), DougaReaction(2052, "reaction"), DougaTutorial(2053, "tutorial"), DougaOther(2054, "other"), // 游戏 Game(1008, "game", 8), GameRpg(2064, "rpg"), GameMmorpg(2065, "mmorpg"), GameStandAlone(2066, "stand_alone"), GameSlg(2067, "slg"), GameTbs(2068, "tbs"), GameRts(2069, "rts"), GameMoba(2070, "moba"), GameStg(2071, "stg"), GameSpg(2072, "spg"), GameAct(2073, "act"), GameMsc(2074, "msc"), GameSim(2075, "sim"), GameOtome(2076, "otome"), GamePuz(2077, "puz"), GameSandbox(2078, "sandbox"), GameOther(2079, "other"), // 鬼畜 Kichiku(1007, "kichiku", 9), KichikuGuide(2059, "guide"), KichikuTheatre(2060, "theatre"), KichikuManualVocaloid(2061, "manual_vocaloid"), KichikuMad(2062, "mad"), KichikuOther(2063, "other"), // 音乐 Music(1003, "music", 10), MusicOriginal(2016, "original"), MusicMv(2017, "mv"), MusicLive(2018, "live"), MusicFanVideos(2019, "fan_videos"), MusicCover(2020, "cover"), MusicPerform(2021, "perform"), MusicVocaloid(2022, "vocaloid"), MusicAiMusic(2023, "ai_music"), MusicRadio(2024, "radio"), MusicTutorial(2025, "tutorial"), MusicCommentary(2026, "commentary"), MusicOther(2027, "other"), // 舞蹈 Dance(1004, "dance", 11), DanceOtaku(2028, "otaku"), DanceHiphop(2029, "hiphop"), DanceGestures(2030, "gestures"), DanceStar(2031, "star"), DanceChina(2032, "china"), DanceTutorial(2033, "tutorial"), DanceBallet(2034, "ballet"), DanceWota(2035, "wota"), DanceOther(2036, "other"), // 影视 Cinephile(1001, "cinephile", 12), CinephileCommentary(2001, "commentary"), CinephileMontage(2002, "montage"), CinephileInformation(2003, "information"), CinephilePorterage(2004, "porterage"), CinephileShortFilm(2005, "shortfilm"), CinephileAi(2006, "ai"), CinephileReaction(2007, "reaction"), CinephileOther(2008, "other"), // 娱乐 Ent(1002, "ent", 13), EntCommentary(2009, "commentary"), EntMontage(2010, "montage"), EntFansVideo(2011, "fans_video"), EntInformation(2012, "information"), EntReaction(2013, "reaction"), EntVariety(2014, "variety"), EntOther(2015, "other"), // 知识 Knowledge(1010, "knowledge", 14), KnowledgeExam(2084, "exam"), KnowledgeLangSkill(2085, "lang_skill"), KnowledgeCampus(2086, "campus"), KnowledgeBusiness(2087, "business"), KnowledgeSocialObservation(2088, "social_observation"), KnowledgePolitics(2089, "politics"), KnowledgeHumanityHistory(2090, "humanity_history"), KnowledgeDesign(2091, "design"), KnowledgePsychology(2092, "psychology"), KnowledgeCareer(2093, "career"), KnowledgeScience(2094, "science"), KnowledgeOther(2095, "other"), // 科技数码 Tech(1012, "tech", 15), TechComputer(2099, "computer"), TechPhone(2100, "phone"), TechPad(2101, "pad"), TechPhotography(2102, "photography"), TechMachine(2103, "machine"), TechCreate(2104, "create"), TechOther(2105, "other"), // 资讯 Information(1009, "information", 16), InformationPolitics(2080, "politics"), InformationOverseas(2081, "overseas"), InformationSocial(2082, "social"), InformationOther(2083, "other"), // 美食 Food(1020, "food", 17), FoodMake(2149, "make"), FoodDetective(2150, "detective"), FoodCommentary(2151, "commentary"), FoodRecord(2152, "record"), FoodOther(2153, "other"), // 小剧场 Shortplay(1021, "shortplay", 18), ShortplayPlot(2154, "plot"), ShortplayLang(2155, "lang"), ShortplayUpVariety(2156, "up_variety"), ShortplayInterview(2157, "interview"), // 汽车 Car(1013, "car", 19), CarCommentary(2106, "commentary"), CarCulture(2107, "culture"), CarLife(2108, "life"), CarTech(2109, "tech"), CarOther(2110, "other"), // 时尚美妆 Fashion(1014, "fashion", 20), FashionMakeup(2111, "makeup"), FashionSkincare(2112, "skincare"), FashionCos(2113, "cos"), FashionOutfits(2114, "outfits"), FashionAccessories(2115, "accessories"), FashionJewelry(2116, "jewelry"), FashionTrick(2117, "trick"), FashionCommentary(2118, "commentary"), FashionOther(2119, "other"), // 体育运动 Sports(1018, "sports", 21), SportsTrend(2133, "trend"), SportsFootball(2134, "football"), SportsBasketball(2135, "basketball"), SportsRunning(2136, "running"), SportsKungfu(2137, "kungfu"), SportsFighting(2138, "fighting"), SportsBadminton(2139, "badminton"), SportsInformation(2140, "information"), SportsMatch(2141, "match"), SportsOther(2142, "other"), // 动物 Animal(1024, "animal", 22), AnimalCat(2167, "cat"), AnimalDog(2168, "dog"), AnimalReptiles(2169, "reptiles"), AnimalScience(2170, "science"), AnimalOther(2171, "other"), // vlog Vlog(1029, "vlog", 23), VlogLife(2194, "life"), VlogStudent(2195, "student"), VlogCareer(2196, "career"), VlogOther(2197, "other"), // 绘画 Painting(1006, "painting", 24), PaintingAcg(2055, "acg"), PaintingNoneAcg(2056, "none_acg"), PaintingTutorial(2057, "tutorial"), PaintingOther(2058, "other"), // 人工智能 Ai(1011, "ai", 25), AiTutorial(2096, "tutorial"), AiInformation(2097, "information"), AiOther(2098, "other"), // 家装房产 Home(1015, "home", 26), HomeTrade(2120, "trade"), HomeRenovation(2121, "renovation"), HomeFurniture(2122, "furniture"), HomeAppliances(2123, "appliances"), // 户外潮流 Outdoors(1016, "outdoors", 27), OutdoorsCamping(2124, "camping"), OutdoorsHiking(2125, "hiking"), OutdoorsExplore(2126, "explore"), OutdoorsOther(2127, "other"), // 健身 Gym(1017, "gym", 28), GymScience(2128, "science"), GymTutorial(2129, "tutorial"), GymRecord(2130, "record"), GymFigure(2131, "figure"), GymOther(2132, "other"), // 手工 Handmake(1019, "handmake", 29), HandmakeHandbook(2143, "handbook"), HandmakeLight(2144, "light"), HandmakeTraditional(2145, "traditional"), HandmakeRelief(2146, "relief"), HandmakeDiy(2147, "diy"), HandmakeOther(2148, "other"), // 旅游出行 Travel(1022, "travel", 30), TravelRecord(2158, "record"), TravelStrategy(2159, "strategy"), TravelCity(2160, "city"), TravelTransport(2161, "transport"), // 三农 Rural(1023, "rural", 31), RuralPlanting(2162, "planting"), RuralFishing(2163, "fishing"), RuralHarvest(2164, "harvest"), RuralTech(2165, "tech"), RuralLife(2166, "life"), // 亲子 Parenting(1025, "parenting", 32), ParentingPregnantCare(2172, "pregnant_care"), ParentingInfantCare(2173, "infant_care"), ParentingTalent(2174, "talent"), ParentingCute(2175, "cute"), ParentingInteraction(2176, "interaction"), ParentingEducation(2177, "education"), ParentingOther(2178, "other"), // 健康 Health(1026, "health", 33), HealthScience(2179, "science"), HealthRegimen(2180, "regimen"), HealthSexes(2181, "sexes"), HealthPsychology(2182, "psychology"), HealthAsmr(2183, "asmr"), HealthOther(2184, "other"), // 情感 Emotion(1027, "emotion", 34), EmotionFamily(2185, "family"), EmotionRomantic(2186, "romantic"), EmotionInterpersonal(2187, "interpersonal"), EmotionGrowth(2188, "growth"), // 生活兴趣 LifeJoy(1030, "life_joy", 35), LifeJoyLeisure(2198, "leisure"), LifeJoyOnSite(2199, "on_site"), LifeJoyArtisticProducts(2200, "artistic_products"), LifeJoyTrendyToys(2201, "trendy_toys"), LifeJoyOther(2202, "other"), // 生活经验 LifeExperience(1031, "life_experience", 36), LifeExperienceSkills(2203, "skills"), LifeExperienceProcedures(2204, "procedures"), LifeExperienceMarriage(2205, "marriage"), // 神秘学 Mysticism(1028, "mysticism", 44), MysticismTarot(2189, "tarot"), MysticismHoroscope(2190, "horoscope"), MysticismMetaphysics(2191, "metaphysics"), MysticismHealing(2192, "healing"), MysticismOther(2193, "other"); companion object { val dougaList = listOf( DougaFanAnime, DougaGarageKit, DougaCosplay, DougaOffline, DougaEditing, DougaCommentary, DougaQuickView, DougaVoice, DougaInformation, DougaInterpret, DougaVup, DougaTokusatsu, DougaPuppetry, DougaComic, DougaMotion, DougaReaction, DougaTutorial, DougaOther ) val gameList = listOf( GameRpg, GameMmorpg, GameStandAlone, GameSlg, GameTbs, GameRts, GameMoba, GameStg, GameSpg, GameAct, GameMsc, GameSim, GameOtome, GamePuz, GameSandbox, GameOther ) val kichikuList = listOf( KichikuGuide, KichikuTheatre, KichikuManualVocaloid, KichikuMad, KichikuOther ) val musicList = listOf( MusicOriginal, MusicMv, MusicLive, MusicFanVideos, MusicCover, MusicPerform, MusicVocaloid, MusicAiMusic, MusicRadio, MusicTutorial, MusicCommentary, MusicOther ) val danceList = listOf( DanceOtaku, DanceHiphop, DanceGestures, DanceStar, DanceChina, DanceTutorial, DanceBallet, DanceWota, DanceOther ) val cinephileList = listOf( CinephileCommentary, CinephileMontage, CinephileInformation, CinephilePorterage, CinephileShortFilm, CinephileAi, CinephileReaction, CinephileOther ) val entList = listOf( EntCommentary, EntMontage, EntFansVideo, EntInformation, EntReaction, EntVariety, EntOther ) val knowledgeList = listOf( KnowledgeExam, KnowledgeLangSkill, KnowledgeCampus, KnowledgeBusiness, KnowledgeSocialObservation, KnowledgePolitics, KnowledgeHumanityHistory, KnowledgeDesign, KnowledgePsychology, KnowledgeCareer, KnowledgeScience, KnowledgeOther ) val techList = listOf( TechComputer, TechPhone, TechPad, TechPhotography, TechMachine, TechCreate, TechOther ) val informationList = listOf( InformationPolitics, InformationOverseas, InformationSocial, InformationOther ) val foodList = listOf( FoodMake, FoodDetective, FoodCommentary, FoodRecord, FoodOther ) val shortplayList = listOf( ShortplayPlot, ShortplayLang, ShortplayUpVariety, ShortplayInterview ) val carList = listOf( CarCommentary, CarCulture, CarLife, CarTech, CarOther ) val fashionList = listOf( FashionMakeup, FashionSkincare, FashionCos, FashionOutfits, FashionAccessories, FashionJewelry, FashionTrick, FashionCommentary, FashionOther ) val sportsList = listOf( SportsTrend, SportsFootball, SportsBasketball, SportsRunning, SportsKungfu, SportsFighting, SportsBadminton, SportsInformation, SportsMatch, SportsOther ) val animalList = listOf( AnimalCat, AnimalDog, AnimalReptiles, AnimalScience, AnimalOther ) val vlogList = listOf( VlogLife, VlogStudent, VlogCareer, VlogOther ) val paintingList = listOf( PaintingAcg, PaintingNoneAcg, PaintingTutorial, PaintingOther ) val aiList = listOf( AiTutorial, AiInformation, AiOther ) val homeList = listOf( HomeTrade, HomeRenovation, HomeFurniture, HomeAppliances ) val outdoorsList = listOf( OutdoorsCamping, OutdoorsHiking, OutdoorsExplore, OutdoorsOther ) val gymList = listOf( GymScience, GymTutorial, GymRecord, GymFigure, GymOther ) val handmakeList = listOf( HandmakeHandbook, HandmakeLight, HandmakeTraditional, HandmakeRelief, HandmakeDiy, HandmakeOther ) val travelList = listOf( TravelRecord, TravelStrategy, TravelCity, TravelTransport ) val ruralList = listOf( RuralPlanting, RuralFishing, RuralHarvest, RuralTech, RuralLife ) val parentingList = listOf( ParentingPregnantCare, ParentingInfantCare, ParentingTalent, ParentingCute, ParentingInteraction, ParentingEducation, ParentingOther ) val healthList = listOf( HealthScience, HealthRegimen, HealthSexes, HealthPsychology, HealthAsmr, HealthOther ) val emotionList = listOf( EmotionFamily, EmotionRomantic, EmotionInterpersonal, EmotionGrowth ) val lifeJoyList = listOf( LifeJoyLeisure, LifeJoyOnSite, LifeJoyArtisticProducts, LifeJoyTrendyToys, LifeJoyOther ) val lifeExperienceList = listOf( LifeExperienceSkills, LifeExperienceProcedures, LifeExperienceMarriage ) val mysticismList = listOf( MysticismTarot, MysticismHoroscope, MysticismMetaphysics, MysticismHealing, MysticismOther ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/region/UgcFeedData.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc.region import dev.aaa1115910.biliapi.entity.ugc.UgcItem data class UgcFeedData( var hasNext: Boolean, var nextPage: UgcFeedPage, var items: List = emptyList() ) { companion object { fun fromRegionFeedRcmd(data: dev.aaa1115910.biliapi.http.entity.region.RegionFeedRcmd): UgcFeedData { return UgcFeedData( hasNext = data.archives.isNotEmpty(), nextPage = UgcFeedPage(), items = data.archives.map { UgcItem.fromRegionRcmdArchive(it) } ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/region/UgcFeedPage.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc.region data class UgcFeedPage( val nextPage: Int = 1 ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/region/UgcRegionData.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc.region import dev.aaa1115910.biliapi.entity.CarouselData import dev.aaa1115910.biliapi.entity.ugc.UgcItem @Deprecated("User region v2 instead") data class UgcRegionData( val carouselData: CarouselData?, val items: List, val next: UgcRegionPage ) { companion object { fun fromRegionDynamic(data: dev.aaa1115910.biliapi.http.entity.region.RegionDynamic): UgcRegionData { return UgcRegionData( carouselData = data.banner?.let { CarouselData.fromUgcRegionDynamicBanner(it) }, items = data.new.map { UgcItem.fromRegionDynamicListItem(it) }, next = UgcRegionPage(data.cBottom) ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/region/UgcRegionListData.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc.region import dev.aaa1115910.biliapi.entity.ugc.UgcItem @Deprecated("User region v2 instead") data class UgcRegionListData( val items: List, val next: UgcRegionPage ) { companion object { fun fromRegionDynamicList(data: dev.aaa1115910.biliapi.http.entity.region.RegionDynamicList): UgcRegionListData { return UgcRegionListData( items = data.new.map { UgcItem.fromRegionDynamicListItem(it) }, next = UgcRegionPage(data.cBottom) ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/ugc/region/UgcRegionPage.kt ================================================ package dev.aaa1115910.biliapi.entity.ugc.region @Deprecated("User region v2 instead") data class UgcRegionPage( val nextPage: Long = 0 ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/user/Author.kt ================================================ package dev.aaa1115910.biliapi.entity.user import dev.aaa1115910.biliapi.http.entity.video.VideoOwner data class Author( val mid: Long, val name: String, val face: String ) { companion object { fun fromVideoOwner(videoOwner: VideoOwner) = Author( mid = videoOwner.mid, name = videoOwner.name, face = videoOwner.face ) fun fromAuthor(author: bilibili.app.archive.v1.Author) = Author( mid = author.mid, name = author.name, face = author.face ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/user/Dynamic.kt ================================================ package dev.aaa1115910.biliapi.entity.user import bilibili.app.dynamic.v2.DynModuleType import bilibili.app.dynamic.v2.Module import bilibili.app.dynamic.v2.ModuleDynamic.ModuleItemCase import bilibili.app.dynamic.v2.Paragraph import bilibili.app.dynamic.v2.VideoType import dev.aaa1115910.biliapi.entity.Picture import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import okhttp3.internal.toLongOrDefault data class DynamicData( val dynamics: List, val hasMore: Boolean, val historyOffset: String, val updateBaseline: String ) { companion object { private val logger = KotlinLogging.logger { } private val availableDynamicTypes = listOf( DynamicType.Av, DynamicType.Draw, DynamicType.Forward, DynamicType.Word, DynamicType.LiveRcmd, DynamicType.Pgc, DynamicType.Article, DynamicType.None ) private val availableWebDynamicTypes = availableDynamicTypes.map { it.webValue } private val availableAppDynamicTypes = availableDynamicTypes.map { it.appValue } fun fromDynamicData(data: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicData) = DynamicData( dynamics = data.items .mapNotNull { if (!availableWebDynamicTypes.contains(it.type)) { logger.warn { "unknown dynamic type ${it.type}, up: ${it.modules.moduleAuthor.name}, date: ${it.modules.moduleAuthor.pubTime}" } return@mapNotNull null } if (it.type == DynamicType.Forward.webValue) { if (!availableWebDynamicTypes.contains(it.orig?.type)) { logger.warn { "unknown dynamic forward type ${it.orig?.type}, up: ${it.modules.moduleAuthor.name}, date: ${it.modules.moduleAuthor.pubTime}" } return@mapNotNull null } } DynamicItem.fromDynamicItem(it) }, hasMore = data.hasMore, historyOffset = data.offset, updateBaseline = data.updateBaseline ).also { logger.info { "updateBaseline: ${data.updateBaseline}" } logger.info { "offset: ${data.offset}" } } fun fromDynamicData(data: bilibili.app.dynamic.v2.DynAllReply) = DynamicData( dynamics = data.dynamicList.listList .mapNotNull { if (!availableAppDynamicTypes.contains(it.cardType)) { logger.warn { "unknown dynamic type ${it.cardType.name}, up: ${it.getAuthorModule()?.author?.name}, date: ${it.getAuthorModule()?.ptimeLabelText}" } return@mapNotNull null } if (it.cardType == bilibili.app.dynamic.v2.DynamicType.forward) { // source not exist if (it.getItemNullModule() != null) { return@mapNotNull DynamicItem.fromDynamicItem(it) } else if (!availableAppDynamicTypes.contains(it.getDynamicModule()?.dynForward?.item?.cardType)) { logger.warn { "unknown dynamic forward type ${it.getDynamicModule()?.dynForward?.item?.cardType}, up: ${it.getAuthorModule()?.author?.name}, date: ${it.getAuthorModule()?.ptimeLabelText}" } return@mapNotNull null } } DynamicItem.fromDynamicItem(it) }, hasMore = data.dynamicList.hasMore, historyOffset = data.dynamicList.historyOffset, updateBaseline = data.dynamicList.updateBaseline ).also { logger.info { "updateBaseline: ${data.dynamicList.updateBaseline}" } logger.info { "historyOffset: ${data.dynamicList.historyOffset}" } } } } data class DynamicItem( var id: String? = null, var commentId: Long = 0, var commentType: Long = 0, var type: DynamicType, val author: DynamicAuthorModule, var video: DynamicVideoModule? = null, var draw: DynamicDrawModule? = null, var word: DynamicWordModule? = null, var liveRcmd: DynamicLiveRcmdModule? = null, var pgc: DynamicPgcModule? = null, var article: DynamicArticleModule? = null, var none: DynamicNoneModule? = null, val footer: DynamicFooterModule? = null, var orig: DynamicItem? = null, var jumpUrl: String? = null ) { companion object { fun fromDynamicItem(item: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem): DynamicItem { val dynamicType = DynamicType.fromWebValue(item.type) val dynamicItem = DynamicItem( id = item.idStr, commentId = item.basic.commentIdStr.toLongOrDefault(0), commentType = item.basic.commentType, type = dynamicType, author = DynamicAuthorModule.fromModuleAuthor(item.modules.moduleAuthor), footer = DynamicFooterModule.fromModuleStat(item.modules.moduleStat) ) when (dynamicType) { DynamicType.Av -> dynamicItem.video = DynamicVideoModule.fromModuleArchive(item.modules.moduleDynamic.major!!.archive!!) DynamicType.UgcSeason -> TODO() DynamicType.Forward -> dynamicItem.apply { word = DynamicWordModule.fromModuleDynamic(item.modules.moduleDynamic) orig = fromDynamicItem(item.orig!!) jumpUrl = item.orig?.basic?.jumpUrl } DynamicType.Word -> dynamicItem.word = DynamicWordModule.fromModuleDynamic(item.modules.moduleDynamic) DynamicType.Draw -> dynamicItem.draw = DynamicDrawModule.fromModuleDynamic(item.modules.moduleDynamic) DynamicType.LiveRcmd -> dynamicItem.liveRcmd = DynamicLiveRcmdModule.fromModuleDynamic(item.modules.moduleDynamic) DynamicType.Pgc -> dynamicItem.pgc = DynamicPgcModule.fromModulePgc(item.modules.moduleDynamic.major!!.pgc!!) DynamicType.Article -> dynamicItem.article = DynamicArticleModule.fromModuleArticle(item.modules.moduleDynamic.major!!.article!!) DynamicType.None -> dynamicItem.none = DynamicNoneModule.fromModuleDynamic(item.modules.moduleDynamic.major!!.none!!) } return dynamicItem } fun fromDynamicItem( item: bilibili.app.dynamic.v2.DynamicItem, isForwardItem: Boolean = false ): DynamicItem { val dynamicType = DynamicType.fromAppValue(item.cardType) val commentType: Long = when (item.cardType) { bilibili.app.dynamic.v2.DynamicType.av, bilibili.app.dynamic.v2.DynamicType.pgc -> 1 bilibili.app.dynamic.v2.DynamicType.draw -> 11 bilibili.app.dynamic.v2.DynamicType.forward, bilibili.app.dynamic.v2.DynamicType.word, bilibili.app.dynamic.v2.DynamicType.article, bilibili.app.dynamic.v2.DynamicType.live_rcmd -> 17 else -> 17 } val dynamicItem = DynamicItem( id = item.extend.dynIdStr, commentId = item.extend.businessId.toLongOrDefault(0), commentType = commentType, // item.extend.rType 总是为 0 //commentType = item.extend.rType, type = dynamicType, author = if (dynamicType == DynamicType.None) { DynamicAuthorModule("", "", -1, "", "") } else if (isForwardItem) { DynamicAuthorModule.fromExtendAndModuleAuthorForward( item.extend, item.getAuthorModuleForward()!! ) } else { DynamicAuthorModule.fromModuleAuthor(item.getAuthorModule()!!) }, video = item.getDynamicModule()?.let { DynamicVideoModule.fromModuleArchive(it.dynArchive).apply { text = item.getDescModule()?.text ?: "" } }, footer = if (!isForwardItem) { // 获取动态详情时 module_list 中没有 module_stat,但 module_bottom 中包含了 module_stat DynamicFooterModule.fromModuleStat( item.getStatModule() ?: item.getBottomModule()!!.moduleStat ) } else null ) when (dynamicType) { DynamicType.Av -> dynamicItem.video = item.getDynamicModule()?.let { DynamicVideoModule.fromModuleArchive(it.dynArchive).apply { text = item.getDescModule()?.text ?: "" } } DynamicType.UgcSeason -> TODO() DynamicType.Draw -> dynamicItem.draw = item.getOpusSummaryModule()?.let { opusSummaryModule -> DynamicDrawModule.fromModuleOpusSummaryAndModuleDynamic( opusSummaryModule, item.getDynamicModule() ) } ?: let { DynamicDrawModule.fromModuleDescAndModuleDynamic( item.getParagraphModule(), item.getDescModule()!!, item.getDynamicModule() ) } DynamicType.Word -> dynamicItem.word = item.getOpusSummaryModule()?.let { opusSummaryModule -> DynamicWordModule.fromModuleOpusSummary(opusSummaryModule) } ?: let { DynamicWordModule.fromModuleDesc(item.getDescModule()!!) } DynamicType.Forward -> dynamicItem.apply { word = DynamicWordModule.fromModuleDesc(item.getDescModule()!!) val item2 = item.getDynamicModule()?.dynForward?.item if (item2 == null) { println() val emptyDynamic = bilibili.app.dynamic.v2.dynamicItem { cardType = bilibili.app.dynamic.v2.DynamicType.dyn_none modules.addAll(item.modulesList) } orig = fromDynamicItem(emptyDynamic, true) } else { orig = fromDynamicItem(item2!!, true) } } DynamicType.LiveRcmd -> dynamicItem.liveRcmd = DynamicLiveRcmdModule.fromModuleDynamic(item.getDynamicModule()!!) DynamicType.Pgc -> dynamicItem.pgc = DynamicPgcModule.fromModulePgc(item.getDynamicModule()!!.dynPgc) DynamicType.Article -> dynamicItem.article = DynamicArticleModule.fromModuleArticle(item.getDynamicModule()!!.dynArticle) DynamicType.None -> dynamicItem.none = DynamicNoneModule.fromModuleDynamic(item.getItemNullModule()!!) } return dynamicItem } } data class DynamicAuthorModule( val author: String, val avatar: String, val mid: Long, val pubTime: String, val pubAction: String ) { companion object { fun fromModuleAuthor(moduleAuthor: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Author) = DynamicAuthorModule( author = moduleAuthor.name, avatar = moduleAuthor.face, mid = moduleAuthor.mid, pubTime = moduleAuthor.pubTime, pubAction = moduleAuthor.pubAction ) fun fromModuleAuthor(moduleAuthor: bilibili.app.dynamic.v2.ModuleAuthor) = DynamicAuthorModule( author = moduleAuthor.author.name, avatar = moduleAuthor.author.face, mid = moduleAuthor.author.mid, pubTime = moduleAuthor.ptimeLabelText, pubAction = "" ) fun fromExtendAndModuleAuthorForward( extend: bilibili.app.dynamic.v2.Extend, moduleAuthorForward: bilibili.app.dynamic.v2.ModuleAuthorForward ) = DynamicAuthorModule( author = extend.origName, avatar = extend.origFace, mid = extend.uid, pubTime = moduleAuthorForward.ptimeLabelText, pubAction = "" ) } } data class DynamicVideoModule( val aid: Long, val bvid: String? = null, val cid: Long, val epid: Int? = null, val seasonId: Int? = null, val title: String, var text: String, val cover: String, val duration: String, val play: String, val danmaku: String, val isChargingArc: Boolean = false, val chargingArcBadge: String = "" ) { companion object { fun fromModuleArchive(moduleArchive: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic.Major.Archive): DynamicVideoModule { val isChargingArc = moduleArchive.badge.text.contains("充电") || moduleArchive.badge.text.contains("限时免费") return DynamicVideoModule( aid = moduleArchive.aid.toLong(), bvid = moduleArchive.bvid, cid = 0, title = moduleArchive.title, text = moduleArchive.desc, cover = moduleArchive.cover, duration = moduleArchive.durationText, play = moduleArchive.stat.play, danmaku = moduleArchive.stat.danmaku, isChargingArc = isChargingArc, chargingArcBadge = if (isChargingArc) moduleArchive.badge.text else "" ) } fun fromModuleArchive(moduleArchive: bilibili.app.dynamic.v2.MdlDynArchive): DynamicVideoModule { val badgeText = moduleArchive.badgeList.firstOrNull()?.text ?: "" val isChargingArc = badgeText.contains("充电") || badgeText.contains("限时免费") return DynamicVideoModule( aid = moduleArchive.avid, bvid = moduleArchive.bvid, cid = moduleArchive.cid, epid = moduleArchive.episodeId.toInt(), seasonId = moduleArchive.pgcSeasonId.toInt(), title = moduleArchive.title, text = "", cover = moduleArchive.cover, duration = moduleArchive.coverLeftText1, play = moduleArchive.coverLeftText2, danmaku = moduleArchive.coverLeftText3, isChargingArc = isChargingArc, chargingArcBadge = if (isChargingArc) badgeText else "" ) } } } data class DynamicFooterModule( val like: Int, val comment: Int, val share: Int ) { companion object { fun fromModuleStat(moduleStat: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Stat?) = moduleStat?.let { DynamicFooterModule( like = moduleStat.like.count, comment = moduleStat.comment.count, share = moduleStat.forward.count ) } fun fromModuleStat(moduleStat: bilibili.app.dynamic.v2.ModuleStat) = DynamicFooterModule( like = moduleStat.like.toInt(), comment = moduleStat.reply.toInt(), share = moduleStat.repost.toInt() ) fun fromModuleBottom(moduleButtom: bilibili.app.dynamic.v2.ModuleButtom) = fromModuleStat(moduleButtom.moduleStat) } } data class DynamicDrawModule( val title: String?, val text: String, val images: List ) { companion object { fun fromModuleDynamic(moduleDynamic: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic) = DynamicDrawModule( title = null, text = moduleDynamic.desc?.text ?: moduleDynamic.major?.opus?.summary?.text ?: "empty text", images = (moduleDynamic.major?.draw?.items?.map(Picture::fromPicture) ?: moduleDynamic.major?.opus?.pics?.map(Picture::fromPicture)) ?.distinctBy { it.url } ?: emptyList() ) fun fromModuleOpusSummaryAndModuleDynamic( moduleOpusSummary: bilibili.app.dynamic.v2.ModuleOpusSummary, moduleDynamic: bilibili.app.dynamic.v2.ModuleDynamic? ): DynamicDrawModule { var title = "" var text = "" val images = mutableListOf() when (val titleContentType = moduleOpusSummary.title.contentCase) { Paragraph.ContentCase.TEXT -> title = moduleOpusSummary.title.text.nodesList .joinToString("") { it.rawText } else -> println("not implemented: ModuleOpusSummary titleContentType: $titleContentType") } when (val summaryContentType = moduleOpusSummary.summary.contentCase) { Paragraph.ContentCase.TEXT -> text = moduleOpusSummary.summary.text.nodesList .joinToString("") { it.rawText } else -> println("not implemented: ModuleOpusSummary summaryContentType: $summaryContentType") } when (val dynamicItemType = moduleDynamic?.moduleItemCase) { null -> println("ModuleDynamic is null") ModuleItemCase.DYN_DRAW -> images.addAll( moduleDynamic.dynDraw.itemsList.map(Picture::fromPicture) ) else -> println("not implemented: ModuleOpusSummary dynamicItemType $dynamicItemType") } return DynamicDrawModule( title = title, text = text, images = images.distinctBy { it.url } ) } fun fromModuleDescAndModuleDynamic( moduleParagraph: bilibili.app.dynamic.v2.ModuleParagraph?, moduleDesc: bilibili.app.dynamic.v2.ModuleDesc, moduleDynamic: bilibili.app.dynamic.v2.ModuleDynamic? ): DynamicDrawModule { var title = "" var text = "" val images = mutableListOf() text = moduleDesc.descList.joinToString("") { it.text } if (moduleParagraph != null && moduleParagraph.isArticleTitle) { when (val titleContentType = moduleParagraph.paragraph.contentCase) { Paragraph.ContentCase.TEXT -> title = moduleParagraph.paragraph.text.nodesList .joinToString("") { it.rawText } else -> println("not implemented: ModuleOpusSummary titleContentType: $titleContentType") } } when (val dynamicItemType = moduleDynamic?.moduleItemCase) { null -> println("ModuleDynamic is null") ModuleItemCase.DYN_DRAW -> images.addAll( moduleDynamic.dynDraw.itemsList.map(Picture::fromPicture) ) else -> println("not implemented: ModuleOpusSummary dynamicItemType $dynamicItemType") } return DynamicDrawModule( title = title, text = text, images = images.distinctBy { it.url } ) } } } data class DynamicWordModule( val text: String ) { companion object { fun fromModuleDynamic(moduleDynamic: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic) = DynamicWordModule( text = moduleDynamic.major?.opus?.summary?.text ?: moduleDynamic.desc?.text ?: "empty content" ) fun fromModuleOpusSummary(moduleOpusSummary: bilibili.app.dynamic.v2.ModuleOpusSummary) = DynamicWordModule( text = moduleOpusSummary.summary.text.nodesList .joinToString("") { it.rawText } ) fun fromModuleDesc(moduleDesc: bilibili.app.dynamic.v2.ModuleDesc) = DynamicWordModule( text = moduleDesc.text ) } } data class DynamicLiveRcmdModule( val title: String, val cover: String, val roomId: Int ) { companion object { private val json = Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true } fun fromModuleDynamic(moduleDynamic: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic): DynamicLiveRcmdModule { val liveRcmdContent = json.decodeFromString(moduleDynamic.major!!.liveRcmd!!.content) return DynamicLiveRcmdModule( title = liveRcmdContent.livePlayInfo.title, cover = liveRcmdContent.livePlayInfo.cover, roomId = liveRcmdContent.livePlayInfo.roomId ) } fun fromModuleDynamic(moduleDynamic: bilibili.app.dynamic.v2.ModuleDynamic): DynamicLiveRcmdModule { val liveRcmdContent = json.decodeFromString(moduleDynamic.dynLiveRcmd.content) return DynamicLiveRcmdModule( title = liveRcmdContent.livePlayInfo.title, cover = liveRcmdContent.livePlayInfo.cover, roomId = liveRcmdContent.livePlayInfo.roomId ) } } @Serializable private data class LiveRcmdContent( @SerialName("live_play_info") val livePlayInfo: LivePlayInfo, @SerialName("live_record_info") val liveRecordInfo: JsonElement? = null, val type: Int ) { @Serializable data class LivePlayInfo( val title: String, @SerialName("parent_area_name") val parentAreaName: String, val cover: String, val online: Int, @SerialName("parent_area_id") val parentAreaId: Int, @SerialName("live_start_time") val liveStartTime: Long, @SerialName("room_id") val roomId: Int, @SerialName("live_status") val liveStatus: Int, @SerialName("room_type") val roomType: Int, @SerialName("play_type") val playType: Int, val link: String, @SerialName("area_id") val areaId: Int, @SerialName("area_name") val areaName: String, @SerialName("watched_show") val watchedShow: WatchedShow, @SerialName("room_paid_type") val roomPaidType: Int, val uid: Long, @SerialName("live_screen_type") val liveScreenType: Int, @SerialName("live_id") val liveId: Long, val pendants: Pendants ) { @Serializable data class WatchedShow( val num: Int, @SerialName("text_small") val textSmall: String, @SerialName("text_large") val textLarge: String, val icon: String, @SerialName("icon_location") val iconLocation: String, @SerialName("icon_web") val iconWeb: String, val switch: Boolean ) @Serializable data class Pendants( val list: JsonElement? = null ) } } } data class DynamicPgcModule( val title: String, val epid: Int, val seasonId: Int, val cover: String, val aid: Long, val cid: Long ) { companion object { fun fromModulePgc(modulePgc: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic.Major.Pgc) = DynamicPgcModule( title = modulePgc.title, epid = modulePgc.epid, seasonId = modulePgc.seasonId, cover = modulePgc.cover, aid = 0, cid = 0 ) fun fromModulePgc(modulePgc: bilibili.app.dynamic.v2.MdlDynPGC): DynamicPgcModule { return DynamicPgcModule( title = modulePgc.title, epid = modulePgc.epid.toInt(), seasonId = modulePgc.seasonId.toInt(), cover = modulePgc.cover, aid = modulePgc.aid, cid = modulePgc.cid ) } } } data class DynamicArticleModule( val title: String, val text: String, val url: String, val label: String, val id: Int, val covers: List ) { companion object { fun fromModuleArticle(moduleDynamic: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic.Major.Article) = DynamicArticleModule( title = moduleDynamic.title, text = moduleDynamic.desc, url = moduleDynamic.jumpUrl, label = moduleDynamic.label, id = moduleDynamic.id, covers = moduleDynamic.covers ) fun fromModuleArticle(moduleArticle: bilibili.app.dynamic.v2.MdlDynArticle): DynamicArticleModule { return DynamicArticleModule( title = moduleArticle.title, text = moduleArticle.desc, url = moduleArticle.uri, label = moduleArticle.label, covers = moduleArticle.coversList, id = moduleArticle.id.toInt() ) } } } data class DynamicNoneModule( val text: String ) { companion object { fun fromModuleDynamic(moduleNone: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic.Major.None) = DynamicNoneModule( text = moduleNone.tips ) fun fromModuleDynamic(moduleNone: bilibili.app.dynamic.v2.ModuleItemNull): DynamicNoneModule { return DynamicNoneModule( text = moduleNone.text ) } } } data class DynamicUgcSeasonModule( val aid: Long, val bvid: String, val cover: String, val desc: String, val duration: String, val url: String, val play: String, val danmaku: String, val title: String ) { companion object { fun fromModuleUgcSeason(moduleDynamic: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem.Modules.Dynamic.Major.UgcSeason) = DynamicUgcSeasonModule( aid = moduleDynamic.aid, bvid = moduleDynamic.bvid, cover = moduleDynamic.cover, desc = moduleDynamic.desc ?: "empty description", duration = moduleDynamic.durationText, url = moduleDynamic.jumpUrl, play = moduleDynamic.stat.play, danmaku = moduleDynamic.stat.danmaku, title = moduleDynamic.title ) } } } data class DynamicVideoData( val videos: List, val hasMore: Boolean, val historyOffset: String, val updateBaseline: String ) { companion object { private val logger = KotlinLogging.logger { } fun fromDynamicData(data: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicData) = DynamicVideoData( videos = data.items.map { DynamicVideo.fromDynamicVideoItem(it) }, hasMore = data.hasMore, historyOffset = data.offset, updateBaseline = data.updateBaseline ).also { logger.info { "updateBaseline: ${data.updateBaseline}" } logger.info { "offset: ${data.offset}" } } fun fromDynamicData(data: bilibili.app.dynamic.v2.DynVideoReply) = DynamicVideoData( videos = data.dynamicList.listList.mapNotNull { DynamicVideo.fromDynamicVideoItem(it) }, hasMore = data.dynamicList.hasMore, historyOffset = data.dynamicList.historyOffset, updateBaseline = data.dynamicList.updateBaseline ).also { logger.info { "updateBaseline: ${data.dynamicList.updateBaseline}" } logger.info { "historyOffset: ${data.dynamicList.historyOffset}" } } } } /** * 动态视频 * * @property aid 视频av号 * @property bvid 视频bv号,grpc pgc 没有bv号 * @property cid 视频cid,仅 grpc 接口 * @property epid 番剧epid,仅 grpc 接口 * @property seasonId 番剧seasonId,仅 grpc 接口 * @property title 视频标题 * @property cover 视频封面 * @property author 视频作者 * @property duration 视频时长,单位秒 * @property play 视频播放量 * @property danmaku 视频弹幕数 * @property avatar 视频作者头像 * @property pubTime 发布时间 */ data class DynamicVideo( val aid: Long, val bvid: String? = null, val cid: Long, val epid: Int? = null, val seasonId: Int? = null, val title: String, val cover: String, val author: String, var authorId: Long = 0, var authorFace: String = "", val duration: Int, val play: Long, val danmaku: Int, val avatar: String, val time: Long = 0L, val pubTime: String? = null, val isChargingArc: Boolean = false, val chargingArcBadge: String = "" ) { companion object { fun fromDynamicVideoItem(item: dev.aaa1115910.biliapi.http.entity.dynamic.DynamicItem): DynamicVideo { val archive = item.modules.moduleDynamic.major!!.archive!! val author = item.modules.moduleAuthor val isChargingArc = archive.badge.text.contains("充电") || archive.badge.text.contains("限时免费") return DynamicVideo( aid = archive.aid.toLong(), bvid = archive.bvid, cid = 0, title = archive.title .replace("动态视频|", ""), cover = archive.cover, author = author.name, authorId = author.mid, authorFace = author.face, duration = convertStringTimeToSeconds(archive.durationText), play = convertStringPlayCountToNumberPlayCount(archive.stat.play), danmaku = convertStringPlayCountToNumberPlayCount(archive.stat.danmaku).toInt(), avatar = author.face, pubTime = author.pubTime, isChargingArc = isChargingArc, chargingArcBadge = if (isChargingArc) archive.badge.text else "" ) } fun fromDynamicVideoItem(item: bilibili.app.dynamic.v2.DynamicItem): DynamicVideo? { val author = item.modulesList.first { it.moduleType == DynModuleType.module_author }.moduleAuthor.author val dynamic = item.modulesList.first { it.moduleType == DynModuleType.module_dynamic }.moduleDynamic val desc = item.modulesList.firstOrNull { it.moduleType == DynModuleType.module_desc }?.moduleDesc val isDynamicVideo = dynamic.dynArchive?.stype == VideoType.video_type_dynamic when (dynamic.moduleItemCase) { ModuleItemCase.DYN_ARCHIVE -> { val archive = dynamic.dynArchive return DynamicVideo( aid = archive.avid, bvid = archive.bvid, cid = archive.cid, title = if (!isDynamicVideo) archive.title else { desc?.text?.replace("动态视频|", "") ?: "NO TITLE" }, cover = archive.cover, author = author.name, authorId = author.mid, authorFace = author.face, duration = convertStringTimeToSeconds(archive.coverLeftText1), play = convertStringPlayCountToNumberPlayCount(archive.coverLeftText2), danmaku = convertStringPlayCountToNumberPlayCount(archive.coverLeftText3).toInt(), avatar = author.face ) } ModuleItemCase.DYN_PGC -> { val pgc = dynamic.dynPgc return DynamicVideo( aid = pgc.aid, bvid = null, cid = pgc.cid, epid = pgc.epid.toInt(), seasonId = pgc.seasonId.toInt(), title = pgc.title, cover = pgc.cover, author = author.name, duration = convertStringTimeToSeconds(pgc.coverLeftText1), play = convertStringPlayCountToNumberPlayCount(pgc.coverLeftText2), danmaku = convertStringPlayCountToNumberPlayCount(pgc.coverLeftText3).toInt(), avatar = author.face ) } ModuleItemCase.DYN_CHARGING_ARCHIVE -> { val chargingArchiveInfo = dynamic.dynChargingArchive.archiveInfo return DynamicVideo( aid = chargingArchiveInfo.avid, bvid = chargingArchiveInfo.bvid, cid = chargingArchiveInfo.cid, title = chargingArchiveInfo.title, cover = chargingArchiveInfo.cover, author = author.name, duration = convertStringTimeToSeconds(chargingArchiveInfo.coverLeftText1), play = convertStringPlayCountToNumberPlayCount(chargingArchiveInfo.coverLeftText2), danmaku = convertStringPlayCountToNumberPlayCount(chargingArchiveInfo.coverLeftText3).toInt(), avatar = author.face ) } else -> { println("unsupported dynamic moduleItemCase: ${dynamic.moduleItemCase}") return null } } } } } private fun convertStringTimeToSeconds(time: String): Int { //部分稿件可能没有时长,Web 接口返回 NaN:NaN:NaN,App 接口返回空字符串 if (time.startsWith("NaN") || time.isBlank()) return 0 val parts = time.split(":") val hours = if (parts.size == 3) parts[0].toInt() else 0 val minutes = parts[parts.size - 2].toInt() val seconds = parts[parts.size - 1].toInt() return (hours * 3600) + (minutes * 60) + seconds } //web 接口获取到的是“xx万”,而 grpc 接口获取到的是“xx.x万播放” private fun convertStringPlayCountToNumberPlayCount(play: String): Long { if (play.startsWith("-")) return 0 runCatching { val number = play .replace("弹幕", "") .replace("观看", "") .replace("播放", "") .substringBefore("万").toFloat() return (if (play.contains("万")) number * 10000 else number).toLong() }.onFailure { println("convert play count [$play] failed: ${it.stackTraceToString()}") } return -1 } enum class DynamicType(val webValue: String, val appValue: bilibili.app.dynamic.v2.DynamicType) { Av("DYNAMIC_TYPE_AV", bilibili.app.dynamic.v2.DynamicType.av), // bilibili hd 端的接口并不会返回合集更新动态 UgcSeason("DYNAMIC_TYPE_UGC_SEASON", bilibili.app.dynamic.v2.DynamicType.ugc_season), Forward("DYNAMIC_TYPE_FORWARD", bilibili.app.dynamic.v2.DynamicType.forward), Word("DYNAMIC_TYPE_WORD", bilibili.app.dynamic.v2.DynamicType.word), Draw("DYNAMIC_TYPE_DRAW", bilibili.app.dynamic.v2.DynamicType.draw), LiveRcmd("DYNAMIC_TYPE_LIVE_RCMD", bilibili.app.dynamic.v2.DynamicType.live_rcmd), Pgc("DYNAMIC_TYPE_PGC_UNION", bilibili.app.dynamic.v2.DynamicType.pgc), Article("DYNAMIC_TYPE_ARTICLE", bilibili.app.dynamic.v2.DynamicType.article), None("DYNAMIC_TYPE_NONE", bilibili.app.dynamic.v2.DynamicType.dyn_none); companion object { fun fromWebValue(webValue: String) = entries.firstOrNull { it.webValue == webValue } ?: throw IllegalArgumentException("unknown type $webValue") fun fromAppValue(appValue: bilibili.app.dynamic.v2.DynamicType) = entries.firstOrNull { it.appValue == appValue } ?: throw IllegalArgumentException("unknown type ${appValue.name}") } } private fun Module.isAuthorModule() = moduleType == DynModuleType.module_author private fun Module.isAuthorModuleForward() = moduleType == DynModuleType.module_author_forward private fun Module.isDescModule() = moduleType == DynModuleType.module_desc private fun Module.isDynamicModule() = moduleType == DynModuleType.module_dynamic private fun Module.isModuleOpusSummary() = moduleType == DynModuleType.module_opus_summary private fun Module.isStatModule() = moduleType == DynModuleType.module_stat private fun Module.isBottomModel() = moduleType == DynModuleType.module_bottom private fun Module.isItemNullModel() = moduleType == DynModuleType.module_item_null private fun Module.isParagraphModel() = moduleType == DynModuleType.module_paragraph private fun bilibili.app.dynamic.v2.DynamicItem.getAuthorModule() = modulesList.firstOrNull { it.isAuthorModule() }?.moduleAuthor private fun bilibili.app.dynamic.v2.DynamicItem.getAuthorModuleForward() = modulesList.firstOrNull { it.isAuthorModuleForward() }?.moduleAuthorForward private fun bilibili.app.dynamic.v2.DynamicItem.getDescModule() = modulesList.firstOrNull { it.isDescModule() }?.moduleDesc private fun bilibili.app.dynamic.v2.DynamicItem.getDynamicModule() = modulesList.firstOrNull { it.isDynamicModule() }?.moduleDynamic private fun bilibili.app.dynamic.v2.DynamicItem.getOpusSummaryModule() = modulesList.firstOrNull { it.isModuleOpusSummary() }?.moduleOpusSummary private fun bilibili.app.dynamic.v2.DynamicItem.getStatModule() = modulesList.firstOrNull { it.isStatModule() }?.moduleStat private fun bilibili.app.dynamic.v2.DynamicItem.getBottomModule() = modulesList.firstOrNull { it.isBottomModel() }?.moduleButtom private fun bilibili.app.dynamic.v2.DynamicItem.getItemNullModule() = modulesList.firstOrNull { it.isItemNullModel() }?.moduleItemNull private fun bilibili.app.dynamic.v2.DynamicItem.getParagraphModule() = modulesList.firstOrNull { it.isParagraphModel() }?.moduleParagraph ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/user/FollowedUser.kt ================================================ package dev.aaa1115910.biliapi.entity.user data class FollowedUser( val mid: Long, val name: String, val avatar: String, val sign: String ) { companion object { fun fromHttpFollowedUser(followedUser: dev.aaa1115910.biliapi.http.entity.user.UserFollowData.FollowedUser) = FollowedUser( mid = followedUser.mid, name = followedUser.uname, avatar = followedUser.face, sign = followedUser.sign ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/user/History.kt ================================================ package dev.aaa1115910.biliapi.entity.user import bilibili.app.interfaces.v1.CursorItem //TODO 暂时仅解析 UGC 和 PGC data class HistoryData( val cursor: Long, val data: List ) { companion object { fun fromHistoryResponse(data: dev.aaa1115910.biliapi.http.entity.history.HistoryData) = HistoryData( cursor = data.cursor.viewAt, data = data.list .filter { it.history.business == "archive" || it.history.business == "pgc" } .map { HistoryItem.fromHistoryItem(it) } ) fun fromHistoryResponse(data: bilibili.app.interfaces.v1.CursorV2Reply) = HistoryData( cursor = data.cursor.max, data = data.itemsList .filter { it.cardItemCase == CursorItem.CardItemCase.CARD_UGC || it.cardItemCase == CursorItem.CardItemCase.CARD_OGV } .map { HistoryItem.fromHistoryItem(it) } ) } } data class HistoryItem( val oid: Long, val bvid: String, val cid: Long, val kid: Long, val epid: Int?, val seasonId: Int?, val title: String, val cover: String, val author: String, val authorId: Long = 0, val authorFace: String = "", val duration: Int, val progress: Int, val type: HistoryItemType, val viewAt: Long ) { companion object { fun fromHistoryItem(item: dev.aaa1115910.biliapi.http.entity.history.HistoryItem) = HistoryItem( oid = item.history.oid, bvid = item.history.bvid, cid = item.history.cid, kid = item.kid, epid = item.history.epid, seasonId = null, title = when(item.history.business){ "archive" -> item.title "pgc" -> item.title + "\n" + item.showTitle else -> item.title }, cover = item.cover, author = item.authorName, authorId = item.authorMid, authorFace = item.authorFace, duration = item.duration, progress = item.progress, type = when (item.history.business) { "archive" -> HistoryItemType.Archive "pgc" -> HistoryItemType.Pgc else -> HistoryItemType.Unknown }, viewAt = item.viewAt ) @Suppress("RemoveRedundantQualifierName") fun fromHistoryItem(item: bilibili.app.interfaces.v1.CursorItem) = HistoryItem( oid = item.oid, bvid = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.bvid CursorItem.CardItemCase.CARD_OGV -> "" else -> "" }, cid = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.cid CursorItem.CardItemCase.CARD_OGV -> 0 else -> 0 }, kid = item.kid, epid = null, seasonId = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_OGV -> item.kid.toInt() else -> null }, title = item.title, cover = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.cover CursorItem.CardItemCase.CARD_OGV -> item.cardOgv.cover else -> "" }, author = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.name CursorItem.CardItemCase.CARD_OGV -> "" else -> "" }, authorId = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.mid CursorItem.CardItemCase.CARD_OGV -> 0 else -> 0 }, duration = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.duration.toInt() CursorItem.CardItemCase.CARD_OGV -> item.cardOgv.duration.toInt() else -> 0 }, progress = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.progress.toInt() CursorItem.CardItemCase.CARD_OGV -> item.cardOgv.progress.toInt() else -> 0 }, type = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> HistoryItemType.Archive CursorItem.CardItemCase.CARD_OGV -> HistoryItemType.Pgc else -> HistoryItemType.Unknown }, viewAt = item.viewAt ) } } enum class HistoryItemType { Unknown, Archive, Pgc } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/user/Space.kt ================================================ package dev.aaa1115910.biliapi.entity.user import java.util.Date data class SpaceVideoData( val videos: List, val page: SpaceVideoPage ) { companion object { fun fromWebSpaceVideoData(webSpaceVideoData: dev.aaa1115910.biliapi.http.entity.user.WebSpaceVideoData) = SpaceVideoData( videos = webSpaceVideoData.list?.vlist ?.map { SpaceVideo.fromSpaceVideoItem(it) } ?: emptyList(), page = SpaceVideoPage( hasNext = (webSpaceVideoData.page?.count ?: 0) > ((webSpaceVideoData.page?.pageNumber ?: 0) * (webSpaceVideoData.page?.pageSize ?: 0)), nextWebPageSize = webSpaceVideoData.page?.pageSize ?: 0, nextWebPageNumber = (webSpaceVideoData.page?.pageNumber ?: 0) + 1 ) ) fun fromAppSpaceVideoData(appSpaceVideoData: dev.aaa1115910.biliapi.http.entity.user.AppSpaceVideoData) = SpaceVideoData( videos = appSpaceVideoData.item .map { SpaceVideo.fromSpaceVideoItem(it) }, page = SpaceVideoPage( hasNext = appSpaceVideoData.hasNext, lastAvid = appSpaceVideoData.item.lastOrNull()?.param?.toLong() ?: 0 ) ) } } data class SpaceVideo( val aid: Long, val bvid: String, val title: String, val cover: String, val author: String, val authorId: Long = 0, val duration: Int, val play: Long, val danmaku: Int, val publishDate: Date, val isChargingArc: Boolean = false, val chargingArcBadge: String = "" ) { companion object { fun fromSpaceVideoItem(spaceVideoItem: dev.aaa1115910.biliapi.http.entity.user.WebSpaceVideoData.SpaceVideoListItem.VListItem) = SpaceVideo( aid = spaceVideoItem.aid, bvid = spaceVideoItem.bvid, title = spaceVideoItem.title, cover = spaceVideoItem.pic, author = spaceVideoItem.author, authorId = spaceVideoItem.mid, duration = convertMmSsToSeconds(spaceVideoItem.length), play = spaceVideoItem.play, danmaku = spaceVideoItem.videoReview, publishDate = Date(spaceVideoItem.created * 1000L), isChargingArc = spaceVideoItem.isChargingArc, chargingArcBadge = spaceVideoItem.elecArcBadge ) fun fromSpaceVideoItem(spaceVideoItem: dev.aaa1115910.biliapi.http.entity.user.AppSpaceVideoData.SpaceVideoItem) = SpaceVideo( aid = spaceVideoItem.param.toLong(), bvid = spaceVideoItem.bvid ?: "", title = spaceVideoItem.title, cover = spaceVideoItem.cover, author = spaceVideoItem.author ?: "", duration = spaceVideoItem.duration, play = spaceVideoItem.play, danmaku = spaceVideoItem.danmaku, publishDate = Date(spaceVideoItem.ctime * 1000L) ) } } private fun convertMmSsToSeconds(time: String): Int { val parts = time.split(":") val minutes = parts[0].toInt() val seconds = parts[1].toInt() return (minutes * 60) + seconds } enum class SpaceVideoOrder(val value: String) { PubDate("pubdate"), Click("click") } data class SpaceVideoPage( val hasNext: Boolean = true, // web val nextWebPageSize: Int = 20, val nextWebPageNumber: Int = 1, // app val lastAvid: Long = 0 ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/user/ToView.kt ================================================ package dev.aaa1115910.biliapi.entity.user import bilibili.app.interfaces.v1.CursorItem //TODO 暂时仅解析 UGC 和 PGC data class ToViewData( val cursor: Long, val data: List ) { companion object { fun fromToViewResponse(data: dev.aaa1115910.biliapi.http.entity.toview.ToViewData) = ToViewData( // cursor = data.cursor.viewAt, cursor = 0, data = data.list // .filter { it.history.business == "archive" || it.history.business == "pgc" } .map { ToViewItem.fromToViewItem(it) } ) // fun fromToViewResponse(data: bilibili.app.interfaces.v1.CursorV2Reply) = ToViewData( // cursor = data.cursor.max, // data = data.itemsList // .filter { it.cardItemCase == CursorItem.CardItemCase.CARD_UGC || it.cardItemCase == CursorItem.CardItemCase.CARD_OGV } // .map { ToViewItem.fromToViewItem(it) } // ) } } data class ToViewItem( val oid: Long, val bvid: String, val cid: Long, val kid: Int, val epid: Int?, val seasonId: Int?, val title: String, val cover: String, val author: String, val authorId: Long, val authorFace: String = "", val duration: Int, val progress: Int, val type: ToViewItemType, val pubdate: Long, val play: Long, val danmaku: Int ) { companion object { fun fromToViewItem(item: dev.aaa1115910.biliapi.http.entity.toview.ToViewItem) = ToViewItem( oid = item.aid, bvid = item.bvid, cid = item.cid, kid = 0, epid = 0, seasonId = null, title = item.title, cover = item.pic, author = item.owner.name, authorId = item.owner.mid, authorFace = item.owner.face, duration = item.duration, progress = item.progress, type = ToViewItemType.Archive, pubdate = item.pubdate, play = item.stat.view, danmaku = item.stat.danmaku // type = when (item.history.business) { // "archive" -> HistoryItemType.Archive // "pgc" -> HistoryItemType.Pgc // else -> HistoryItemType.Unknown // } ) @Suppress("RemoveRedundantQualifierName") fun fromToViewItem(item: bilibili.app.interfaces.v1.CursorItem) = ToViewItem( oid = item.oid, bvid = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.bvid CursorItem.CardItemCase.CARD_OGV -> "" else -> "" }, cid = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.cid CursorItem.CardItemCase.CARD_OGV -> 0 else -> 0 }, kid = item.kid.toInt(), epid = null, seasonId = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_OGV -> item.kid.toInt() else -> null }, title = item.title, cover = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.cover CursorItem.CardItemCase.CARD_OGV -> item.cardOgv.cover else -> "" }, author = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.name CursorItem.CardItemCase.CARD_OGV -> "" else -> "" }, authorId = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.mid CursorItem.CardItemCase.CARD_OGV -> 0 else -> 0 }, duration = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.duration.toInt() CursorItem.CardItemCase.CARD_OGV -> item.cardOgv.duration.toInt() else -> 0 }, progress = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> item.cardUgc.progress.toInt() CursorItem.CardItemCase.CARD_OGV -> item.cardOgv.progress.toInt() else -> 0 }, type = when (item.cardItemCase) { CursorItem.CardItemCase.CARD_UGC -> ToViewItemType.Archive CursorItem.CardItemCase.CARD_OGV -> ToViewItemType.Pgc else -> ToViewItemType.Unknown }, pubdate = -1, play = -1, danmaku = -1 ) } } enum class ToViewItemType { Unknown, Archive, Pgc } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/Dimension.kt ================================================ package dev.aaa1115910.biliapi.entity.video data class Dimension( val width: Int, val height: Int, val isVertical: Boolean = width < height ) { companion object { fun fromDimension(dimension: bilibili.app.archive.v1.Dimension) = Dimension( width = dimension.width.toInt(), height = dimension.height.toInt() ) fun fromDimension(dimension: dev.aaa1115910.biliapi.http.entity.video.Dimension) = Dimension( width = dimension.width, height = dimension.height ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/Heartbeat.kt ================================================ package dev.aaa1115910.biliapi.entity.video enum class HeartbeatVideoType(val value: Int) { Video(3), Season(4), Course(10) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/RelatedVideo.kt ================================================ package dev.aaa1115910.biliapi.entity.video import bilibili.app.view.v1.authorOrNull import dev.aaa1115910.biliapi.entity.ugc.toSmartDate import dev.aaa1115910.biliapi.entity.user.Author data class RelatedVideo( val aid: Long, val cover: String, val title: String, val duration: Int, val author: Author?, val jumpToSeason: Boolean, val epid: Int?, val view: Long, val danmaku: Int, val pubTime: String? = null, val isChargingArchive: Boolean = false ) { companion object { fun fromRelate(relate: bilibili.app.view.v1.Relate) = RelatedVideo( aid = relate.aid, cover = relate.pic, title = relate.title, duration = relate.duration.toInt(), author = relate.authorOrNull?.let { Author.fromAuthor(it) } ?: relate.desc?.let { Author(0, it, "") }, jumpToSeason = relate.goto.needJumpToSeason(), epid = if (relate.goto.needJumpToSeason()) relate.uri.substringBeforeLast("?") .substringAfterLast("/ep").toInt() else null, view = relate.stat.view, danmaku = relate.stat.danmaku ) fun fromRelate(relate: dev.aaa1115910.biliapi.http.entity.video.RelatedVideoInfo) = RelatedVideo( aid = relate.aid, cover = relate.pic, title = relate.title, duration = relate.duration, author = relate.owner.let { Author.fromVideoOwner(it) }, jumpToSeason = false, epid = null, view = relate.stat.view, danmaku = relate.stat.danmaku, pubTime = relate.pubdate.toLong().toSmartDate(), isChargingArchive = relate.isChargingArchive || relate.chargingPay != null ) } } private fun String.needJumpToSeason() = this.contains("bangumi_ep") || this.contains("special") ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/Subtitle.kt ================================================ package dev.aaa1115910.biliapi.entity.video //TODO 将 lanDoc 内括号的内容分离出来,将其作为 Badge 显示 /** * 字幕 * * @param id 字幕 id * @param lang 字幕语言,例如 zh-Hant ai-zh ai-en * @param langDoc 字幕语言名称 * @param url 字幕地址 * @param type 字幕类型 人工/AI * @param aiType AI 字幕类型 生成/翻译 * @param aiStatus AI 字幕状态 */ data class Subtitle( val id: Long, val lang: String, val langDoc: String, val url: String, var type: SubtitleType, val aiType: SubtitleAiType, var aiStatus: SubtitleAiStatus ) { companion object { fun fromSubtitleItem(data: dev.aaa1115910.biliapi.http.entity.video.VideoMoreInfo.SubtitleItem) = Subtitle( id = data.id, lang = data.lan, langDoc = data.lanDoc + if (data.type == SubtitleType.AI.ordinal) "(AI)" else "", url = data.subtitleUrl, type = when (data.type) { 0 -> SubtitleType.CC 1 -> SubtitleType.AI else -> SubtitleType.CC }, aiType = when (data.aiType) { 0 -> SubtitleAiType.Normal 1 -> SubtitleAiType.Translate else -> SubtitleAiType.Normal }, aiStatus = when (data.aiStatus) { 0 -> SubtitleAiStatus.None 1 -> SubtitleAiStatus.Exposure 2 -> SubtitleAiStatus.Assist else -> SubtitleAiStatus.None } ) fun fromSubtitleItem(data: bilibili.community.service.dm.v1.SubtitleItem) = Subtitle( id = data.id, lang = data.lan, langDoc = data.lanDoc, url = data.subtitleUrl, type = when (data.type) { bilibili.community.service.dm.v1.SubtitleType.CC -> SubtitleType.CC bilibili.community.service.dm.v1.SubtitleType.AI -> SubtitleType.AI else -> SubtitleType.CC }, aiType = when (data.aiType) { bilibili.community.service.dm.v1.SubtitleAiType.Normal -> SubtitleAiType.Normal bilibili.community.service.dm.v1.SubtitleAiType.Translate -> SubtitleAiType.Translate else -> SubtitleAiType.Normal }, aiStatus = when (data.aiStatus) { bilibili.community.service.dm.v1.SubtitleAiStatus.None -> SubtitleAiStatus.None bilibili.community.service.dm.v1.SubtitleAiStatus.Exposure -> SubtitleAiStatus.Exposure bilibili.community.service.dm.v1.SubtitleAiStatus.Assist -> SubtitleAiStatus.Assist else -> SubtitleAiStatus.None } ) } } enum class SubtitleType { CC, AI } enum class SubtitleAiType { Normal, Translate } enum class SubtitleAiStatus { None, Exposure, Assist } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/Tag.kt ================================================ package dev.aaa1115910.biliapi.entity.video data class Tag( val id: Int, val name: String ) { companion object { fun fromTag(tag: dev.aaa1115910.biliapi.http.entity.video.Tag) = Tag( id = tag.tagId, name = tag.tagName ) fun fromTag(tag: bilibili.app.view.v1.Tag) = Tag( id = tag.id.toInt(), name = tag.name ) fun fromTag(tag: dev.aaa1115910.biliapi.http.entity.video.VideoDetail.Tag) = Tag( id = tag.tagId, name = tag.tagName ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/VideoDetail.kt ================================================ package dev.aaa1115910.biliapi.entity.video import bilibili.app.view.v1.ReqUser import bilibili.app.view.v1.ViewReply import bilibili.app.view.v1.ugcSeasonOrNull import dev.aaa1115910.biliapi.entity.user.Author import dev.aaa1115910.biliapi.entity.video.season.UgcSeason import dev.aaa1115910.biliapi.http.entity.video.VideoStat import java.util.Date data class VideoDetail( val bvid: String, val aid: Long, val cid: Long, val cover: String, val title: String, val publishDate: Date, val description: String, val stat: Stat, val author: Author, val pages: List, val ugcSeason: UgcSeason?, val relatedVideos: List, val redirectToEp: Boolean, val epid: Int?, val argueTip: String?, val tags: List, val userActions: UserActions, var history: History, var playerIcon: PlayerIcon? = null, var isChargingArc: Boolean = false, var chargingArcBadge: String = "" ) { companion object { fun fromViewReply(viewReply: ViewReply): VideoDetail { if (!viewReply.hasActivitySeason()) { return VideoDetail( bvid = viewReply.bvid, aid = viewReply.arc.aid, cid = viewReply.arc.firstCid, cover = viewReply.arc.pic, title = viewReply.arc.title, publishDate = Date(viewReply.arc.pubdate * 1000L), description = viewReply.arc.desc, stat = Stat.fromStat(viewReply.arc.stat), author = Author.fromAuthor(viewReply.arc.author), pages = viewReply.pagesList.map { VideoPage.fromViewPage(it) }, ugcSeason = viewReply.ugcSeasonOrNull?.let { UgcSeason.fromUgcSeason(it) }, relatedVideos = viewReply.relatesList.map { RelatedVideo.fromRelate(it) }, redirectToEp = viewReply.arc.redirectUrl.contains("ep"), epid = runCatching { viewReply.arc.redirectUrl.split("ep", "?")[1].toInt() }.getOrNull(), argueTip = viewReply.argueMsg.takeIf { it.isNotEmpty() }, tags = viewReply.tagList.map { Tag.fromTag(it) }, userActions = UserActions.fromReqUser(viewReply.reqUser), history = History.fromHistory(viewReply.history), playerIcon = viewReply.playerIcon?.let { PlayerIcon.fromPlayerIcon(it) } ) } else { return VideoDetail( bvid = viewReply.activitySeason.bvid, aid = viewReply.activitySeason.arc.aid, cid = viewReply.activitySeason.arc.firstCid, cover = viewReply.activitySeason.arc.pic, title = viewReply.activitySeason.arc.title, publishDate = Date(viewReply.activitySeason.arc.pubdate * 1000L), description = viewReply.activitySeason.arc.desc, stat = Stat.fromStat(viewReply.activitySeason.arc.stat), author = Author.fromAuthor(viewReply.activitySeason.arc.author), pages = viewReply.activitySeason.pagesList.map { VideoPage.fromViewPage(it) }, ugcSeason = viewReply.activitySeason.ugcSeasonOrNull?.let { UgcSeason.fromUgcSeason( it ) }, relatedVideos = viewReply.relatesList.map { RelatedVideo.fromRelate(it) }, redirectToEp = viewReply.activitySeason.arc.redirectUrl.contains("ep"), epid = runCatching { viewReply.activitySeason.arc.redirectUrl.split("ep", "?")[1].toInt() }.getOrNull(), argueTip = viewReply.activitySeason.argueMsg.takeIf { it.isNotEmpty() }, tags = viewReply.tagList.map { Tag.fromTag(it) }, userActions = UserActions.fromReqUser(viewReply.activitySeason.reqUser), history = History.fromHistory(viewReply.activitySeason.history), playerIcon = viewReply.activitySeason.playerIcon?.let { PlayerIcon.fromPlayerIcon( it ) } ) } } fun fromVideoDetail(videoDetail: dev.aaa1115910.biliapi.http.entity.video.VideoDetail) = VideoDetail( bvid = videoDetail.view.bvid, aid = videoDetail.view.aid, cid = videoDetail.view.cid, cover = videoDetail.view.pic, title = videoDetail.view.title, publishDate = Date(videoDetail.view.pubdate * 1000L), description = videoDetail.view.desc, stat = Stat.fromVideoStat(videoDetail.view.stat), author = Author.fromVideoOwner(videoDetail.view.owner), pages = videoDetail.view.pages.map { VideoPage.fromVideoPage(it) }, ugcSeason = videoDetail.view.ugcSeason?.let { UgcSeason.fromUgcSeason(it) }, relatedVideos = videoDetail.related?.map { RelatedVideo.fromRelate(it) } ?: emptyList(), redirectToEp = videoDetail.view.redirectUrl?.contains("ep") ?: false, epid = videoDetail.view.redirectUrl?.split("ep", "?")?.get(1)?.toInt(), argueTip = videoDetail.view.stat.argueMsg.takeIf { it.isNotEmpty() }, tags = videoDetail.tags.map { Tag.fromTag(it) }, userActions = UserActions(), history = History(0, 0), playerIcon = null, isChargingArc = videoDetail.view.isUpowerExclusive, chargingArcBadge = if (videoDetail.view.isUpowerExclusive) { if (videoDetail.view.isUpowerPlay) "限时免费" else "充电专属" } else "" ) } data class Stat( val view: Long, val danmaku: Int, val reply: Int, val favorite: Int, val coin: Int, val share: Int, val like: Int, val historyRank: Int ) { companion object { fun fromStat(stat: bilibili.app.archive.v1.Stat) = Stat( view = stat.view, danmaku = stat.danmaku, reply = stat.reply, favorite = stat.fav, coin = stat.coin, share = stat.share, like = stat.like, historyRank = stat.hisRank ) fun fromVideoStat(videoStat: VideoStat) = Stat( view = videoStat.view, danmaku = videoStat.danmaku, reply = videoStat.reply, favorite = videoStat.favorite, coin = videoStat.coin, share = videoStat.share, like = videoStat.like, historyRank = videoStat.hisRank ) } } data class History( val progress: Int, val lastPlayedCid: Long ) { companion object { fun fromHistory(history: bilibili.app.view.v1.History) = History( progress = history.progress.toInt(), lastPlayedCid = history.cid ) } } data class PlayerIcon( val idle: String, val moving: String ) { companion object { fun fromPlayerIcon(playerIcon: dev.aaa1115910.biliapi.http.entity.video.VideoMoreInfo.PlayerIcon?) = if (playerIcon != null && playerIcon.url1 != null && playerIcon.url2 != null) PlayerIcon( idle = playerIcon.url2, moving = playerIcon.url1 ) else null fun fromPlayerIcon(playerIcon: bilibili.app.view.v1.PlayerIcon) = PlayerIcon( idle = playerIcon.url2, moving = playerIcon.url1 ) fun fromPlayerIcon(playerIcon: dev.aaa1115910.biliapi.http.entity.season.AppSeasonData.PlayerIcon?) = playerIcon?.let { PlayerIcon( idle = playerIcon.url2 ?: return@let null, moving = playerIcon.url1 ?: return@let null ) } } } } data class UserActions( var like: Boolean = false, var favorite: Boolean = false, var coin: Boolean = false, var dislike: Boolean = false ) { companion object { fun fromReqUser(reqUser: ReqUser): UserActions { return UserActions( like = reqUser.like == 1, favorite = reqUser.favorite == 1, coin = reqUser.coin == 1, dislike = reqUser.dislike == 1 ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/VideoPage.kt ================================================ package dev.aaa1115910.biliapi.entity.video data class VideoPage( var cid: Long, val index: Int, val title: String, val duration: Int, val dimension: Dimension ) { companion object { fun fromViewPage(viewPage: bilibili.app.view.v1.ViewPage) = VideoPage( cid = viewPage.page.cid, index = viewPage.page.page, title = viewPage.page.part, duration = viewPage.page.duration.toInt(), dimension = Dimension.fromDimension(viewPage.page.dimension) ) fun fromVideoPage(videoPage: dev.aaa1115910.biliapi.http.entity.video.VideoPage) = VideoPage( cid = videoPage.cid, index = videoPage.page, title = videoPage.part, duration = videoPage.duration, dimension = Dimension.fromDimension(videoPage.dimension) ) fun fromPage(page: bilibili.app.archive.v1.Page) = VideoPage( cid = page.cid, index = page.page, title = page.part, duration = page.duration.toInt(), dimension = Dimension.fromDimension(page.dimension) ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/VideoShot.kt ================================================ package dev.aaa1115910.biliapi.entity.video import dev.aaa1115910.biliapi.http.BiliHttpApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.DataInputStream data class VideoShot( val times: List, val images: List, val imageCountX: Int, val imageCountY: Int, val imageWidth: Int, val imageHeight: Int ) { companion object { suspend fun fromVideoShot(videoShot: dev.aaa1115910.biliapi.http.entity.video.VideoShot): VideoShot? = withContext(Dispatchers.IO) { val images = videoShot.image.map { imageUrl -> async { runCatching { BiliHttpApi.download(imageUrl) }.getOrNull() } }.awaitAll() if (images.contains(null)) { println("download video shot images failed") return@withContext null } val timeBinary = runCatching { BiliHttpApi.download( videoShot.pvData ?: throw IllegalStateException("pvData is null") ) }.onFailure { println("download video shot times binary failed: ${it.stackTraceToString()}") return@withContext null }.getOrNull() val times = mutableListOf() runCatching { DataInputStream(ByteArrayInputStream(timeBinary)).use { //if has next while (it.available() > 0) { times.add(it.readUnsignedShort().toUShort()) } } }.onFailure { println("parse video shot times binary failed: ${it.stackTraceToString()}") return@withContext null } return@withContext VideoShot( times = times.drop(1), images = images, imageCountX = videoShot.imgXLen, imageCountY = videoShot.imgYLen, imageWidth = videoShot.imgXSize, imageHeight = videoShot.imgYSize ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/season/Episode.kt ================================================ package dev.aaa1115910.biliapi.entity.video.season import dev.aaa1115910.biliapi.entity.video.Dimension import dev.aaa1115910.biliapi.entity.video.VideoPage /** * 剧集视频 * * @param id 剧集id * @param aid av号 * @param bvid bv号 * @param cid cid * @param epid epid * @param title 标题 在投稿视频中显示为分 p 标题; * 在剧集中,如果存在完整标题,则该标题内容为纯数字,用于显示“第 x 话”,若完整标题内容为空时,显示该分集标题,例如“正片”“中文” * @param longTitle 完整标题,仅在剧集中存在,如果不存在完整标题,则该标题为空 * @param cover 封面 * @param duration 时长 * @param dimension 分辨率 */ data class Episode( val id: Int, val aid: Long, val bvid: String, val cid: Long, val epid: Int? = null, val title: String, val longTitle: String, val cover: String, val duration: Int, val pubDate: Long = 0, val dimension: Dimension?, val pages: List ) { companion object { fun fromEpisode(episode: bilibili.app.view.v1.Episode) = Episode( id = episode.id.toInt(), aid = episode.aid, bvid = episode.bvid, cid = episode.cid, title = episode.title, longTitle = episode.title, cover = episode.cover, duration = episode.page.duration.toInt(), dimension = Dimension.fromDimension(episode.page.dimension), pages = episode.pagesList.map { VideoPage.fromPage(it) } ) fun fromEpisode(episode: dev.aaa1115910.biliapi.http.entity.video.UgcSeason.Section.Episode) = Episode( id = episode.id, aid = episode.aid, bvid = episode.bvid, cid = episode.cid, title = episode.title, longTitle = episode.title, cover = episode.arc.pic, duration = episode.arc.duration, pubDate = episode.arc.pubDate.toLong(), dimension = Dimension.fromDimension(episode.page.dimension), pages = episode.pages.map { VideoPage.fromVideoPage(it) } ) fun fromEpisode(episode: dev.aaa1115910.biliapi.http.entity.season.Episode) = Episode( id = episode.id, aid = episode.aid, cid = episode.cid, bvid = episode.bvid, cover = episode.cover, title = episode.title, longTitle = episode.longTitle, epid = episode.epId, duration = episode.duration, pubDate = episode.pubTime, dimension = episode.dimension?.let { Dimension.fromDimension(it) }, pages = emptyList() ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/season/PgcSeason.kt ================================================ package dev.aaa1115910.biliapi.entity.video.season import dev.aaa1115910.biliapi.http.entity.season.OtherSeason data class UgcSeason( val id: Int, val title: String, val cover: String, val sections: List
) { companion object { fun fromUgcSeason(ugcSeason: bilibili.app.view.v1.UgcSeason) = UgcSeason( id = ugcSeason.id.toInt(), title = ugcSeason.title, cover = ugcSeason.cover, sections = ugcSeason.sectionsList.map { Section.fromSection(it) } ) fun fromUgcSeason(ugcSeason: dev.aaa1115910.biliapi.http.entity.video.UgcSeason) = UgcSeason( id = ugcSeason.id, title = ugcSeason.title, cover = ugcSeason.cover, sections = ugcSeason.sections.map { Section.fromSection(it) } ) } } /** * 剧集信息 * * @param seasonId 剧集id * @param title 剧集标题,仅 App 端 * @param shortTitle 剧集短标题,用于 TabRow 处显示 */ data class PgcSeason( val seasonId: Int, val title: String?, val shortTitle: String, val cover: String, val horizontalCover: String? ) { companion object { fun fromSeason(season: OtherSeason): PgcSeason { return PgcSeason( seasonId = season.seasonId, title = season.title, shortTitle = season.seasonTitle, cover = season.cover, horizontalCover = season.horizontalCover ?: season.newEp.cover ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/season/SeasonDetail.kt ================================================ package dev.aaa1115910.biliapi.entity.video.season import dev.aaa1115910.biliapi.entity.video.VideoDetail.PlayerIcon import dev.aaa1115910.biliapi.http.entity.season.AppSeasonData import dev.aaa1115910.biliapi.http.entity.season.WebSeasonData /** * 剧集详细信息 * * @param title 剧集标题 * @param originTitle 剧集原始标题,仅出现在具有外文名时,且仅 App 端有此字段 * @param styles 剧集风格 * @param cover 封面 * @param description 剧集简介 * @param subType 剧集类型,需要用到上报播放记录 * @param seasonId 剧集id * @param userStatus 用户信息,例如购买记录,追剧情况,播放记录等 * @param publish 发布状态 * @param newEpDesc 最新一集的描述, * @param seasons 同系列剧集列表, * @param episodes 剧集视频列表 * @param sections 相关视频列表 */ data class SeasonDetail( val title: String, val originTitle: String? = null, val styles: List, val cover: String, val description: String, val subType: Int, val seasonId: Int, val userStatus: UserStatus, val publish: Publish, val newEpDesc: String = "", val seasons: List = emptyList(), val episodes: List = emptyList(), val sections: List
= emptyList(), var playerIcon: PlayerIcon? = null ) { companion object { fun fromSeasonData(seasonData: WebSeasonData): SeasonDetail { return SeasonDetail( title = seasonData.title, originTitle = null, styles = seasonData.styles, cover = seasonData.cover, description = seasonData.evaluate, subType = seasonData.type, seasonId = seasonData.seasonId, userStatus = UserStatus.fromUserStatus(seasonData.userStatus), publish = Publish.fromPublish(seasonData.publish), newEpDesc = seasonData.newEp.desc, seasons = seasonData.seasons.map { PgcSeason.fromSeason(it) }, episodes = seasonData.episodes.map { Episode.fromEpisode(it) }, sections = seasonData.section.map { Section.fromSection(it) } .filter { it.episodes.isNotEmpty() } // 过滤掉跳转别的 pgc 的视频后可能出现空列表 ) } fun fromSeasonData(seasonData: AppSeasonData): SeasonDetail { return SeasonDetail( title = seasonData.title, originTitle = seasonData.originName, styles = seasonData.styles.map { it.name }, cover = seasonData.cover, description = seasonData.evaluate, subType = seasonData.type, seasonId = seasonData.seasonId, userStatus = UserStatus.fromUserStatus(seasonData.userStatus), publish = Publish.fromPublish(seasonData.publish), newEpDesc = seasonData.newEp.desc, seasons = seasonData.modules .firstOrNull { it.style == "season" } ?.data?.seasons ?.map { PgcSeason.fromSeason(it) } ?: emptyList(), episodes = seasonData.modules .firstOrNull { it.style == "positive" } ?.data?.episodes ?.map { Episode.fromEpisode(it) } ?: emptyList(), sections = seasonData.modules .filter { it.style == "section" } .map { Section.fromModule(it) }, playerIcon = PlayerIcon.fromPlayerIcon(seasonData.playerIcon) ) } } /** * 用户信息 * * @param follow 已追剧 * @param pay 已购买 * @param progress 观看记录 */ data class UserStatus( val follow: Boolean, val pay: Boolean, val progress: Progress? = null ) { companion object { fun fromUserStatus(userStatus: WebSeasonData.UserStatus): UserStatus { return UserStatus( follow = userStatus.follow == 1, pay = userStatus.pay == 1, progress = userStatus.progress?.let { Progress( lastEpId = it.lastEpId, lastEpIndex = it.lastEpIndex, lastTime = it.lastTime ) } ) } fun fromUserStatus(userStatus: AppSeasonData.UserStatus): UserStatus { return UserStatus( follow = userStatus.follow == 1, pay = userStatus.pay == 1, progress = userStatus.progress?.let { Progress( lastEpId = it.lastEpId, lastEpIndex = it.lastEpIndex, lastTime = it.lastTime ) } ) } } /** * 观看记录 * * @param lastEpId 最后观看的epid * @param lastEpIndex 最后观看的ep标题 * @param lastTime 最后观看的时间(秒) */ data class Progress( val lastEpId: Int, val lastEpIndex: String, val lastTime: Int ) } data class Publish( val isPublished: Boolean, val publishDate: String ) { companion object { fun fromPublish(publish: dev.aaa1115910.biliapi.http.entity.season.Publish): Publish { return Publish( isPublished = publish.isStarted, publishDate = publish.pubTimeShow ) } } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/entity/video/season/Section.kt ================================================ package dev.aaa1115910.biliapi.entity.video.season data class Section( val id: Long, val title: String, val episodes: List ) { companion object { fun fromSection(section: dev.aaa1115910.biliapi.http.entity.video.UgcSeason.Section) = Section( id = section.id, title = section.title, episodes = section.episodes.map { Episode.fromEpisode(it) } ) fun fromSection(section: bilibili.app.view.v1.Section) = Section( id = section.id, title = section.title, episodes = section.episodesList.map { Episode.fromEpisode(it) } ) fun fromModule(module: dev.aaa1115910.biliapi.http.entity.season.AppSeasonData.Module) = Section( id = module.id, title = module.title, episodes = module.data.episodes.map { Episode.fromEpisode(it) } ) fun fromSection(section: dev.aaa1115910.biliapi.http.entity.season.SeasonSection) = Section( id = section.id, title = section.title, episodes = section.episodes.map { Episode.fromEpisode(it) } .filter { it.aid != 0L } // aid 为 0 的视频是跳转到其它 PGC 页面的“链接”,暂不适配( ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/grpc/utils/Channel.kt ================================================ package dev.aaa1115910.biliapi.grpc.utils import bilibili.metadata.device.device import bilibili.metadata.locale.locale import bilibili.metadata.metadata import bilibili.metadata.network.NetworkType import bilibili.metadata.network.network import dev.aaa1115910.biliapi.http.util.BiliAppConf import io.grpc.CallOptions import io.grpc.Channel import io.grpc.ClientCall import io.grpc.ClientInterceptor import io.grpc.ForwardingClientCall.SimpleForwardingClientCall import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder import io.grpc.MethodDescriptor import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.header import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import io.grpc.Metadata as GrpcMetadata fun generateChannel( accessKey: String, buvid: String, endPoint: String = BiliAppConf.GRPC_HOST, port: Int = BiliAppConf.GRPC_PORT, enableTransportSecurity: Boolean = true ): ManagedChannel = ManagedChannelBuilder .forAddress(endPoint, port) .apply { if (enableTransportSecurity) useTransportSecurity() else usePlaintext() } .executor(Dispatchers.IO.asExecutor()) .intercept(MetadataInterceptor(accessKey, buvid)) .build() private class MetadataInterceptor( private val accessKey: String, private val buvid: String ) : ClientInterceptor { override fun interceptCall( method: MethodDescriptor, callOptions: CallOptions, next: Channel ): ClientCall { return object : SimpleForwardingClientCall(next.newCall(method, callOptions)) { override fun start(responseListener: Listener, headers: GrpcMetadata) { headers.apply { putAuthorization(accessKey) putMetadataBin(accessKey, buvid) putDeviceBin(buvid) putLocalBin() putNetworkBin() } super.start(responseListener, headers) } } } } fun GrpcMetadata.putAuthorization(accessKey: String) { put( GrpcMetadata.Key.of("authorization", GrpcMetadata.ASCII_STRING_MARSHALLER), "identify_v1 $accessKey" ) } fun GrpcMetadata.putMetadataBin(accessKey: String, buvid: String) { put( GrpcMetadata.Key.of("x-bili-metadata-bin", GrpcMetadata.BINARY_BYTE_MARSHALLER), metadata { this.accessKey = accessKey mobiApp = BiliAppConf.MOBI_APP device = BiliAppConf.DEVICE build = BiliAppConf.APP_BUILD_CODE channel = BiliAppConf.CHANNEL this.buvid = buvid platform = BiliAppConf.PLATFORM }.toByteArray() ) } fun GrpcMetadata.putDeviceBin(buvid: String) { put( io.grpc.Metadata.Key.of("x-bili-device-bin", GrpcMetadata.BINARY_BYTE_MARSHALLER), device { appId = BiliAppConf.APP_ID mobiApp = BiliAppConf.MOBI_APP device = BiliAppConf.DEVICE build = BiliAppConf.APP_BUILD_CODE channel = BiliAppConf.CHANNEL this.buvid = buvid platform = BiliAppConf.PLATFORM }.toByteArray() ) } fun GrpcMetadata.putLocalBin() { put( io.grpc.Metadata.Key.of("x-bili-local-bin", GrpcMetadata.BINARY_BYTE_MARSHALLER), locale { timezone = BiliAppConf.TIMEZONE }.toByteArray() ) } fun GrpcMetadata.putNetworkBin() { put( io.grpc.Metadata.Key.of("x-bili-network-bin", GrpcMetadata.BINARY_BYTE_MARSHALLER), network { type = NetworkType.WIFI }.toByteArray() ) } @OptIn(ExperimentalEncodingApi::class) fun HttpRequestBuilder.generateGrpcProxyHeaders( accessKey: String, buvid: String ) { header("authorization", "identify_v1 $accessKey") header("x-bili-metadata-bin", Base64.encode(metadata { this.accessKey = accessKey mobiApp = BiliAppConf.MOBI_APP device = BiliAppConf.DEVICE build = BiliAppConf.APP_BUILD_CODE channel = BiliAppConf.CHANNEL this.buvid = buvid platform = BiliAppConf.PLATFORM }.toByteArray())) header("x-bili-device-bin", Base64.encode(device { appId = BiliAppConf.APP_ID mobiApp = BiliAppConf.MOBI_APP device = BiliAppConf.DEVICE build = BiliAppConf.APP_BUILD_CODE channel = BiliAppConf.CHANNEL this.buvid = buvid platform = BiliAppConf.PLATFORM }.toByteArray())) header("x-bili-local-bin", Base64.encode(locale { timezone = BiliAppConf.TIMEZONE }.toByteArray())) header("x-bili-network-bin", Base64.encode(network { type = NetworkType.WIFI }.toByteArray())) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/grpc/utils/StatusExtends.kt ================================================ package dev.aaa1115910.biliapi.grpc.utils import bilibili.rpc.Status import com.google.protobuf.Message //import com.google.rpc.Status import io.grpc.Metadata import io.grpc.StatusException // 如果没有设置 java_multiple_files = true; 那么就需要使用下面的代码 // 这时候生成的代码会将很多 class 放在同一个 class 里面 // 例如 bilibili.rpc.Status 会被放在 bilibili.rpc.StatusOuterClass$Status 中 // 这时候直接从 typeUrl 中获取 class 名称是不对的 /* @Suppress("UNCHECKED_CAST") fun Status.getTypeClass(): Class { val protoClassName = this.detailsList.first().typeUrl.split("/").last() val splitProtoClassNames = protoClassName.split(".") val nameClass = splitProtoClassNames .subList(0, splitProtoClassNames.size - 1) .joinToString(".") + ".${splitProtoClassNames.last()}OuterClass$${splitProtoClassNames.last()}" return Class.forName(nameClass) as Class } */ @Suppress("UNCHECKED_CAST") fun Status.getTypeClass(): Class { val nameClass = this.detailsList.first().typeUrl.split("/").last() return Class.forName(nameClass) as Class } fun Status.getDetail(): Any { val clazz = this.getTypeClass() return this.detailsList.first().unpack(clazz) } fun handleGrpcException(it: Throwable) { when (it) { is StatusException -> { val statusDetailsKey = Metadata.Key.of( "grpc-status-details-bin", Metadata.BINARY_BYTE_MARSHALLER ) val data = it.trailers[statusDetailsKey] val status = Status.parseFrom(data).getDetail() when (status) { is bilibili.rpc.Status -> { throw IllegalStateException(status.message) } is common.ErrorProto -> { throw IllegalStateException(status.message) } } } else -> throw it } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/BiliHttpApi.kt ================================================ package dev.aaa1115910.biliapi.http import com.tfowl.ktor.client.plugins.JsoupPlugin import dev.aaa1115910.biliapi.entity.pgc.PgcType import dev.aaa1115910.biliapi.http.BiliHttpApi.getRegionDynamic import dev.aaa1115910.biliapi.BiliApiConstants.USER_AGENT_WEB import dev.aaa1115910.biliapi.http.entity.BiliResponse import dev.aaa1115910.biliapi.http.entity.BiliResponseWithoutData import dev.aaa1115910.biliapi.http.entity.VVoucherException import dev.aaa1115910.biliapi.http.entity.danmaku.DanmakuData import dev.aaa1115910.biliapi.http.entity.danmaku.DanmakuResponse import dev.aaa1115910.biliapi.http.entity.dynamic.DynamicData import dev.aaa1115910.biliapi.http.entity.dynamic.DynamicDetailData import dev.aaa1115910.biliapi.http.entity.history.HistoryData import dev.aaa1115910.biliapi.http.entity.home.RcmdIndexData import dev.aaa1115910.biliapi.http.entity.home.RcmdTopData import dev.aaa1115910.biliapi.http.entity.index.IndexResultData import dev.aaa1115910.biliapi.entity.pgc.index.PgcIndexConditionData import dev.aaa1115910.biliapi.http.entity.pgc.PgcFeedData import dev.aaa1115910.biliapi.http.entity.pgc.PgcFeedV3Data import dev.aaa1115910.biliapi.http.entity.pgc.PgcWebInitialStateData import dev.aaa1115910.biliapi.http.entity.region.RegionBanner import dev.aaa1115910.biliapi.http.entity.region.RegionDynamic import dev.aaa1115910.biliapi.http.entity.region.RegionDynamicList import dev.aaa1115910.biliapi.http.entity.region.RegionFeedRcmd import dev.aaa1115910.biliapi.http.entity.region.RegionLocs import dev.aaa1115910.biliapi.http.entity.reply.CommentData import dev.aaa1115910.biliapi.http.entity.reply.CommentReplyData import dev.aaa1115910.biliapi.http.entity.search.AppSearchSquareData import dev.aaa1115910.biliapi.http.entity.search.KeywordSuggest import dev.aaa1115910.biliapi.http.entity.search.SearchResultData import dev.aaa1115910.biliapi.http.entity.search.SearchTendingData import dev.aaa1115910.biliapi.http.entity.search.WebSearchSquareData import dev.aaa1115910.biliapi.http.entity.season.AppSeasonData import dev.aaa1115910.biliapi.http.entity.season.FollowingSeasonAppData import dev.aaa1115910.biliapi.http.entity.season.FollowingSeasonWebData import dev.aaa1115910.biliapi.http.entity.season.SeasonFollowData import dev.aaa1115910.biliapi.http.entity.season.WebSeasonData import dev.aaa1115910.biliapi.http.entity.toview.ToViewData import dev.aaa1115910.biliapi.http.entity.user.AppSpaceVideoData import dev.aaa1115910.biliapi.http.entity.user.FollowAction import dev.aaa1115910.biliapi.http.entity.user.FollowActionSource import dev.aaa1115910.biliapi.http.entity.user.MyInfoData import dev.aaa1115910.biliapi.http.entity.user.RelationData import dev.aaa1115910.biliapi.http.entity.user.RelationStat import dev.aaa1115910.biliapi.http.entity.user.UserCardData import dev.aaa1115910.biliapi.http.entity.user.UserFollowData import dev.aaa1115910.biliapi.http.entity.user.UserInfoData import dev.aaa1115910.biliapi.http.entity.user.WebSpaceVideoData import dev.aaa1115910.biliapi.http.entity.user.favorite.FavoriteFolderInfo import dev.aaa1115910.biliapi.http.entity.user.favorite.FavoriteFolderInfoListData import dev.aaa1115910.biliapi.http.entity.user.favorite.FavoriteItemIdListResponse import dev.aaa1115910.biliapi.http.entity.user.favorite.UserFavoriteFoldersData import dev.aaa1115910.biliapi.http.entity.user.garb.Equip import dev.aaa1115910.biliapi.http.entity.user.garb.EquipPart import dev.aaa1115910.biliapi.http.entity.video.AddCoin import dev.aaa1115910.biliapi.http.entity.video.ArchiveRelation import dev.aaa1115910.biliapi.http.entity.video.CheckSentCoin import dev.aaa1115910.biliapi.http.entity.video.CheckVideoFavoured import dev.aaa1115910.biliapi.http.entity.video.GaiaVgateRegisterData import dev.aaa1115910.biliapi.http.entity.video.GaiaVgateValidateData import dev.aaa1115910.biliapi.http.entity.video.PlayUrlData import dev.aaa1115910.biliapi.http.entity.video.PlayUrlV2Data import dev.aaa1115910.biliapi.http.entity.video.PopularVideoData import dev.aaa1115910.biliapi.http.entity.video.RelatedVideosResponse import dev.aaa1115910.biliapi.http.entity.video.SetVideoFavorite import dev.aaa1115910.biliapi.http.entity.video.Tag import dev.aaa1115910.biliapi.http.entity.video.TagDetail import dev.aaa1115910.biliapi.http.entity.video.TagTopVideosResponse import dev.aaa1115910.biliapi.http.entity.video.Timeline import dev.aaa1115910.biliapi.http.entity.video.TimelineAppData import dev.aaa1115910.biliapi.http.entity.video.VideoDetail import dev.aaa1115910.biliapi.http.entity.video.VideoInfo import dev.aaa1115910.biliapi.http.entity.video.VideoMoreInfo import dev.aaa1115910.biliapi.http.entity.video.VideoOnlineTotal import dev.aaa1115910.biliapi.http.entity.video.VideoShot import dev.aaa1115910.biliapi.http.entity.web.NavResponseData import dev.aaa1115910.biliapi.http.plugins.BiliUserAgent import dev.aaa1115910.biliapi.http.util.BiliAppConf import dev.aaa1115910.biliapi.http.util.BiliDns import dev.aaa1115910.biliapi.http.util.encApiSign import dev.aaa1115910.biliapi.http.util.skipAddBuvid3Cookie import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.forms.FormDataContent import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.readRawBytes import io.ktor.http.Parameters import io.ktor.http.URLProtocol import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.InternalAPI import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.CoroutineScope import bilibili.community.service.dm.v1.DmSegMobileReply import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jsoup.nodes.Document import java.util.concurrent.ConcurrentHashMap import javax.xml.parsers.DocumentBuilderFactory @Suppress("SpellCheckingInspection") object BiliHttpApi { private var endPoint: String = "api.bilibili.com" private lateinit var client: HttpClient // 用于获取 sessData 的提供者,由应用层设置 var sessDataProvider: () -> String = { "" } // 用于获取 buvid3 的提供者,由应用层设置 var buvid3Provider: () -> String? = { null } private val json = Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true } var wbiImgKey: String? = null var wbiSubKey: String? = null private var wbiLastRefreshDate = 0L // 缓存相关变量 private data class CacheEntry( val data: T, var expireTime: Long ) private val videoMoreInfoCache = ConcurrentHashMap>>() init { createClient() CoroutineScope(Dispatchers.IO).launch { updateWbi() } } private fun createClient() { client = HttpClient(OkHttp) { engine { config { dns(BiliDns) } } BiliUserAgent() install(ContentNegotiation) { json(json) } install(ContentEncoding) { deflate(1.0F) gzip(0.9F) } install(HttpRequestRetry) { retryOnException(maxRetries = 2) } install(JsoupPlugin) defaultRequest { url { host = endPoint protocol = URLProtocol.HTTPS } } }.apply { encApiSign() } } /** * 检查响应体是否包含风控 v_voucher。 * * 当 API 返回 `{"code":0,"data":{"v_voucher":"voucher_xxx"}}` 时, * 表示触发了风控,需要通过 Geetest 验证,此方法会抛出 [VVoucherException]。 */ private fun checkForVVoucher(bodyText: String) { runCatching { val root = Json.parseToJsonElement(bodyText).jsonObject val code = root["code"]?.jsonPrimitive?.contentOrNull?.toIntOrNull() ?: return if (code != 0) return val data = root["data"]?.jsonObject ?: root["result"]?.jsonObject ?: return val vVoucher = data["v_voucher"]?.jsonPrimitive?.contentOrNull if (!vVoucher.isNullOrBlank()) { throw VVoucherException(vVoucher) } }.onFailure { if (it is VVoucherException) throw it // JSON 解析失败不影响正常流程 } } /** * 获取热门视频列表 */ suspend fun getPopularVideoData( pageNumber: Int = 1, pageSize: Int = 20, sessData: String = "" ): BiliResponse = client.get("/x/web-interface/popular") { parameter("pn", pageNumber) parameter("ps", pageSize) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取视频详细信息 */ suspend fun getVideoInfo( av: Int? = null, bv: String? = null, sessData: String? = null ): BiliResponse = client.get("/x/web-interface/view") { parameter("aid", av) parameter("bvid", bv) sessData?.let { header("Cookie", "SESSDATA=$sessData;") } }.body() /** * 获取视频超详细信息 */ suspend fun getVideoDetail( av: Long? = null, bv: String? = null, sessData: String? = null ): BiliResponse { val response = client.get("/x/web-interface/wbi/view/detail") { av?.let { parameter("aid", av) } bv?.let { parameter("bvid", bv) } sessData?.let { header("Cookie", "SESSDATA=$sessData;") } skipAddBuvid3Cookie() } println("getVideoDetail:" + response.bodyAsText()) return response.body() } /** * 获取视频流 */ suspend fun getVideoPlayUrl( av: Long? = null, bv: String? = null, cid: Long, qn: Int? = 80, fnval: Int? = 1, fnver: Int? = 0, fourk: Int? = 1, session: String? = null, otype: String = "json", type: String = "", platform: String = "oc", sessData: String? = null, dedeUserID: Long? = null, gaiaVtoken: String? = null ): BiliResponse { val response = client.get("/x/player/wbi/playurl") { require(av != null || bv != null) { "av and bv cannot be null at the same time" } parameter("avid", av) parameter("bvid", bv) parameter("cid", cid) parameter("qn", qn) parameter("fnval", fnval) parameter("fnver", fnver) parameter("fourk", fourk) parameter("session", session) parameter("otype", otype) parameter("type", type) parameter("platform", platform) gaiaVtoken?.let { parameter("gaia_vtoken", it) } if (sessData.isNullOrEmpty()) { // parameter("voice_balance", 1) parameter("web_location", "1315873") parameter("gaia_source", "pre-load") parameter("isGaiaAvoided", "true") parameter("try_look", "1") } sessData?.let { header("Cookie", "SESSDATA=$sessData;DedeUserID=$dedeUserID") } } val bodyText = response.bodyAsText() // println(bodyText) checkForVVoucher(bodyText) return json.decodeFromString(bodyText) } /** * 获取剧集视频流 */ suspend fun getPgcVideoPlayUrl( av: Long? = null, bv: String? = null, epid: Int? = null, cid: Long? = null, qn: Int? = null, fnval: Int? = null, fnver: Int? = null, fourk: Int? = null, session: String? = null, supportMultiAudio: Boolean? = null, drmTechType: Int? = null, fromClient: String? = null, sessData: String? = null, dedeUserID: Long? = null, buvid3: String? = null, gaiaVtoken: String? = null ): BiliResponse { val response = client.get("/pgc/player/web/playurl") { require(av != null || bv != null) { "av and bv cannot be null at the same time" } require(epid != null || cid != null) { "epid and cid cannot be null at the same time" } av?.let { parameter("avid", it) } bv?.let { parameter("bvid", it) } epid?.let { parameter("ep_id", it) } cid?.let { parameter("cid", it) } qn?.let { parameter("qn", it) } fnval?.let { parameter("fnval", it) } fnver?.let { parameter("fnver", it) } fourk?.let { parameter("fourk", it) } session?.let { parameter("session", it) } supportMultiAudio?.let { parameter("support_multi_audio", it) } drmTechType?.let { parameter("drm_tech_type", it) } fromClient?.let { parameter("from_client", it) } gaiaVtoken?.let { parameter("gaia_vtoken", it) } val cookieParts = mutableListOf() sessData?.let { cookieParts.add("SESSDATA=$it") } dedeUserID?.let { cookieParts.add("DedeUserID=$it") } buvid3?.let { cookieParts.add("buvid3=$it") } if (cookieParts.isNotEmpty()) header("Cookie", cookieParts.joinToString(";")) //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") } val bodyText = response.bodyAsText() checkForVVoucher(bodyText) return json.decodeFromString(bodyText) } /** * 获取剧集视频流 v2 */ suspend fun getPgcVideoPlayUrlV2( av: Long? = null, bv: String? = null, epid: Int? = null, cid: Long? = null, qn: Int? = null, fnval: Int? = null, fnver: Int? = null, fourk: Int? = null, session: String? = null, supportMultiAudio: Boolean? = null, drmTechType: Int? = null, fromClient: String? = null, sessData: String? = null, buvid3: String? = null, gaiaVtoken: String? = null ): BiliResponse { val response = client.get("/pgc/player/web/v2/playurl") { av?.let { parameter("avid", it) } bv?.let { parameter("bvid", it) } epid?.let { parameter("ep_id", it) } cid?.let { parameter("cid", it) } qn?.let { parameter("qn", it) } fnval?.let { parameter("fnval", it) } fnver?.let { parameter("fnver", it) } fourk?.let { parameter("fourk", it) } session?.let { parameter("session", it) } supportMultiAudio?.let { parameter("support_multi_audio", it) } drmTechType?.let { parameter("drm_tech_type", it) } fromClient?.let { parameter("from_client", it) } gaiaVtoken?.let { parameter("gaia_vtoken", it) } val cookieParts = mutableListOf() sessData?.let { cookieParts.add("SESSDATA=$it") } buvid3?.let { cookieParts.add("buvid3=$it") } if (cookieParts.isNotEmpty()) { val cookieString = cookieParts.joinToString(";") println("PGC v2 Cookie: $cookieString") header("Cookie", cookieString) } else { println("PGC v2 Cookie is empty! sessData=$sessData, buvid3=$buvid3") } //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") } val bodyText = response.bodyAsText() checkForVVoucher(bodyText) return json.decodeFromString(bodyText) } /** * 通过[cid]获取视频弹幕 */ suspend fun getDanmakuXml( cid: Long, sessData: String = "" ): DanmakuResponse { val xmlChannel = client.get("/x/v1/dm/list.so") { parameter("oid", cid) header("Cookie", "SESSDATA=$sessData;") }.bodyAsChannel() val dbFactory = DocumentBuilderFactory.newInstance() val dBuilder = dbFactory.newDocumentBuilder() val doc = withContext(Dispatchers.IO) { dBuilder.parse(xmlChannel.toInputStream()) } doc.documentElement.normalize() val chatServer = doc.getElementsByTagName("chatserver").item(0).textContent val chatId = doc.getElementsByTagName("chatid").item(0).textContent.toLong() val maxLimit = doc.getElementsByTagName("maxlimit").item(0).textContent.toInt() val state = doc.getElementsByTagName("state").item(0).textContent.toInt() val realName = doc.getElementsByTagName("real_name").item(0).textContent.toInt() val source = runCatching { doc.getElementsByTagName("source").item(0).textContent }.getOrDefault("") val data = mutableListOf() val danmakuNodes = doc.getElementsByTagName("d") for (i in 0 until danmakuNodes.length) { val danmakuNode = danmakuNodes.item(i) val p = danmakuNode.attributes.item(0).textContent val text = danmakuNode.textContent data.add(DanmakuData.fromString(p, text)) } return DanmakuResponse(chatServer, chatId, maxLimit, state, realName, source, data) } /** * 通过[cid]和[avid]获取视频弹幕 * 支持分段获取 * * @param cid 视频 cid * @param avid 视频 avid * @param segmentIndex 分段索引,从 1 开始。每 6min 一包 * @param sessData 用户认证 cookie * @return 弹幕数据列表 */ suspend fun getDanmakuSeg( cid: Long, avid: Long, segmentIndex: Int = 1, sessData: String = "" ): List { val responseBytes = client.get("/x/v2/dm/wbi/web/seg.so") { parameter("type", 1) // 1:视频 parameter("oid", cid) parameter("pid", avid) parameter("segment_index", segmentIndex) header("Cookie", "SESSDATA=$sessData;") }.readRawBytes() val reply = bilibili.community.service.dm.v1.DmSegMobileReply.parseFrom(responseBytes) return reply.elemsList.map { elem -> DanmakuData( time = elem.progress / 1000f, // ms -> s type = elem.mode, size = elem.fontsize, color = elem.color, timestamp = (elem.ctime / 1000).toInt(), // ms -> s pool = elem.pool, midHash = elem.midHash, dmid = elem.id, level = elem.weight, // weight 用于屏蔽等级 text = elem.content ) } } /** * 获取动态列表 * * @param type 返回数据额类型 all:全部 video:视频投稿 pgc:追番追剧 article:专栏 * @param offset 请求第2页及其之后时填写,填写上一次请求获得的offset */ suspend fun getDynamicList( timezoneOffset: Int = -480, type: String = "all", page: Int = 1, offset: String? = null, sessData: String = "" ): BiliResponse = client.get("/x/polymer/web-dynamic/v1/feed/all") { parameter("timezone_offset", timezoneOffset) parameter("type", type) parameter("page", page) offset?.let { parameter("offset", offset) } header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取动态详情 * * @param id 动态id */ suspend fun getDynamicDetail( timezoneOffset: Int = -480, id: String, features: String? = null, sessData: String = "" ): BiliResponse = client.get("/x/polymer/web-dynamic/v1/detail") { parameter("timezone_offset", timezoneOffset) parameter("id", id) features?.let { parameter("features", it) } header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取用户[uid]的详细信息 */ suspend fun getUserInfo( uid: Long, sessData: String = "" ): BiliResponse = client.get("/x/space/wbi/acc/info") { parameter("mid", uid) header("Cookie", "SESSDATA=$sessData;") // 风控 parameter("dm_img_list", "[]") parameter("dm_img_str", "V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ") parameter( "dm_cover_img_str", "QU5HTEUgKEFNRCwgQU1EIFJhZGVvbiA3ODBNIEdyYXBoaWNzICgweDAwMDAxNUJGKSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzAsIEQzRDExKUdvb2dsZSBJbmMuIChBTU" ) parameter("dm_img_inter", "{\"ds\":[],\"wh\":[4769,2793,43],\"of\":[285,570,285]}") header("referer", "https://space.bilibili.com") }.body() /** * 获取用户[uid]的卡片信息 * * @param uid 用户id * @param photo 是否请求用户主页头图 */ suspend fun getUserCardInfo( mid: Long, photo: Boolean = false, sessData: String = "" ): BiliResponse = client.get("/x/web-interface/card") { parameter("mid", mid) parameter("photo", photo) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 通过[sessData]获取用户个人信息 */ suspend fun getUserSelfInfo( buvid3: String? = null, sessData: String = "" ): BiliResponse = client.get("/x/space/myinfo") { if (buvid3 != null && sessData.isNotEmpty()) { header("Cookie", "buvid3=$buvid3; SESSDATA=$sessData;") } else { header("Cookie", "SESSDATA=$sessData;") } }.body() /** * 获取截止至目标id[max]和目标时间[viewAt]历史记录 * * @param business 分类 貌似无效 * @param pageSize 页面大小 */ suspend fun getHistories( max: Long = 0, business: String = "", viewAt: Long = 0, pageSize: Int = 20, sessData: String = "" ): BiliResponse = client.get("/x/web-interface/history/cursor") { parameter("max", max) parameter("business", business) parameter("view_at", viewAt) parameter("ps", pageSize) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取稍后再看列表 */ suspend fun getToView( // max: Long = 0, // business: String = "", // viewAt: Long = 0, // pageSize: Int = 20, sessData: String = "" ): BiliResponse = client.get("/x/v2/history/toview") { // parameter("max", max) // parameter("business", business) // parameter("view_at", viewAt) // parameter("ps", pageSize) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 删除历史记录[kid] */ suspend fun deleteHistory( kid: String, csrf: String, sessData: String ): BiliResponseWithoutData = client.post("/x/v2/history/delete") { setBody( FormDataContent( Parameters.build { append("kid", kid) append("csrf", csrf) } ) ) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 清空历史记录 */ suspend fun clearHistory( csrf: String, sessData: String ): BiliResponseWithoutData = client.post("/x/v2/history/clear") { setBody( FormDataContent( Parameters.build { append("csrf", csrf) } ) ) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 从稍后再看列表中删除视频[avid] */ suspend fun deleteToView( avid: Long, csrf: String, sessData: String ): BiliResponseWithoutData = client.post("/x/v2/history/toview/del") { setBody( FormDataContent( Parameters.build { append("aid", "$avid") append("csrf", csrf) } ) ) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 清空稍后再看列表中的视频[avid] */ suspend fun clearToView( csrf: String, sessData: String ): BiliResponseWithoutData = client.post("/x/v2/history/toview/clear") { setBody( FormDataContent( Parameters.build { append("csrf", csrf) } ) ) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 添加视频到稍后再看列表中[avid] */ suspend fun addToView( avid: Long? = null, bvid: String? = null, csrf: String, sessData: String ): BiliResponseWithoutData = client.post("/x/v2/history/toview/add") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } setBody( FormDataContent( Parameters.build { avid?.let { append("aid", "$avid") } bvid?.let { append("bvid", bvid) } append("csrf", csrf) } ) ) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取与视频[avid]或[bvid]有关的相关推荐视频 */ suspend fun getRelatedVideos( avid: Long? = null, bvid: String? = null ): RelatedVideosResponse = client.get("/x/web-interface/archive/related") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } parameter("aid", avid) parameter("bvid", bvid) }.body() /** * 获取收藏夹[mediaId]的元数据 */ suspend fun getFavoriteFolderInfo( mediaId: Long, accessKey: String? = null, sessData: String? = null ): BiliResponse = client.get("/x/v3/fav/folder/info") { checkToken(accessKey, sessData) parameter("media_id", mediaId) accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() /** * 获取用户[mid]的所有收藏夹信息 * * @param type 目标内容属性 默认为全部 0:全部 2:视频稿件 * @param rid 目标内容id 视频稿件:视频稿件avid */ suspend fun getAllFavoriteFoldersInfo( mid: Long, type: Int = 0, rid: Long? = null, accessKey: String? = null, sessData: String? = null ): BiliResponse = client.get("/x/v3/fav/folder/created/list-all") { checkToken(accessKey, sessData) parameter("up_mid", mid) parameter("type", type) parameter("rid", rid) accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() /** * 获取收藏夹[mediaId]的详细内容 * * @param tid 分区tid 默认为全部分区 0:全部分区 * @param keyword 搜索关键字 * @param order 排序方式 按收藏时间:mtime 按播放量: view 按投稿时间:pubtime * @param type 查询范围 0:当前收藏夹(对应media_id) 1:全部收藏夹 * @param pageSize 每页数量 定义域:1-20 * @param pageNumber 页码 默认为1 * @param platform 平台标识 可为web(影响内容列表类型) */ suspend fun getFavoriteList( mediaId: Long, tid: Int = 0, keyword: String? = null, order: String? = null, type: Int = 0, pageSize: Int = 20, pageNumber: Int = 1, platform: String? = null, accessKey: String? = null, sessData: String? = null ): BiliResponse = client.get("/x/v3/fav/resource/list") { checkToken(accessKey, sessData) parameter("media_id", mediaId) parameter("tid", tid) parameter("keyword", keyword) parameter("order", order) parameter("type", type) parameter("ps", pageSize) parameter("pn", pageNumber) parameter("platform", platform) accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() /** * 获取收藏夹[mediaId]的全部内容id */ suspend fun getFavoriteIdList( mediaId: Long, platform: String? = null, accessKey: String? = null, sessData: String? = null ): FavoriteItemIdListResponse = client.get("/x/v3/fav/resource/ids") { checkToken(accessKey, sessData) parameter("media_id", mediaId) parameter("platform", platform) accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() /** * 上报视频播放心跳 * * @param avid 稿件avid avid与bvid任选一个 * @param bvid 稿件bvid avid与bvid任选一个 * @param cid 视频cid 用于识别分P * @param epid 番剧epid * @param sid 番剧ssid * @param mid 当前用户mid * @param playedTime 视频播放进度 单位为秒 默认为0 * @param realtime 总计播放时间 单位为秒 * @param startTs 开始播放时刻 时间戳 * @param type 视频类型 3:投稿视频 4:剧集 10:课程 * @param subType 剧集副类型 当type=4时本参数有效 1:番剧 2:电影 3:纪录片 4:国创 5:电视剧 7:综艺 * @param dt 2 * @param playType 播放动作 0:播放中 1:开始播放 2:暂停 3:继续播放 * @param csrf bili_jct * @param sessData SESSDATA */ suspend fun sendHeartbeat( avid: Long? = null, bvid: String? = null, cid: Long? = null, epid: Int? = null, sid: Int? = null, mid: Long? = null, playedTime: Int? = null, realtime: Int? = null, startTs: Long? = null, type: Int? = null, subType: Int? = null, dt: Int? = null, playType: Int? = null, csrf: String? = null, sessData: String ): String = client.post("/x/click-interface/web/heartbeat") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } setBody( FormDataContent( Parameters.build { avid?.let { append("aid", "$it") } bvid?.let { append("bvid", it) } cid?.let { append("cid", "$it") } epid?.let { append("epid", "$it") } sid?.let { append("sid", "$it") } mid?.let { append("mid", "$it") } playedTime?.let { append("played_time", "$it") } realtime?.let { append("realtime", "$it") } startTs?.let { append("start_ts", "$it") } type?.let { append("type", "$it") } subType?.let { append("sub_type", "$it") } dt?.let { append("dt", "$it") } playType?.let { append("play_type", "$it") } csrf?.let { append("csrf", it) } } )) header("Cookie", "SESSDATA=$sessData;") }.bodyAsText() suspend fun sendHeartbeat( avid: Long? = null, bvid: String? = null, cid: Long? = null, epid: Int? = null, sid: Int? = null, mid: Long? = null, playedTime: Int? = null, realtime: Int? = null, startTs: Long? = null, type: Int? = null, subType: Int? = null, dt: Int? = null, playType: Int? = null, accessKey: String? = null ): String = client.post("/x/v2/history/report") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } setBody( FormDataContent( Parameters.build { avid?.let { append("aid", "$it") } bvid?.let { append("bvid", it) } cid?.let { append("cid", "$it") } epid?.let { append("epid", "$it") } sid?.let { append("sid", "$it") } mid?.let { append("mid", "$it") } playedTime?.let { append("progress", "$it") } realtime?.let { append("realtime", "$it") } startTs?.let { append("start_ts", "$it") } type?.let { append("type", "$it") } subType?.let { append("sub_type", "$it") } dt?.let { append("dt", "$it") } playType?.let { append("play_type", "$it") } accessKey?.let { append("access_key", it) } } )) }.bodyAsText() /** * 获取视频[avid]的[cid]视频更多信息,例如播放进度 */ suspend fun getVideoMoreInfo( avid: Long, cid: Long, sessData: String, buvid3: String ): BiliResponse { val cacheKey = "$avid-$cid-$sessData" val currentTime = System.currentTimeMillis() // 清理所有过期的缓存数据 val iterator = videoMoreInfoCache.entries.iterator() while (iterator.hasNext()) { val entry = iterator.next() if (currentTime > entry.value.expireTime) { iterator.remove() } } videoMoreInfoCache[cacheKey]?.let { cacheEntry -> // 缓存存在且有效,重置TTL并返回缓存结果 cacheEntry.expireTime = currentTime + 1000L return cacheEntry.data } // 发起请求 val response: BiliResponse = client.get("/x/player/wbi/v2") { parameter("aid", avid) parameter("cid", cid) header("Cookie", "buvid3=$buvid3; SESSDATA=$sessData;") }.body() // 缓存结果 videoMoreInfoCache[cacheKey] = CacheEntry(response, currentTime + 1000L) return response } /** * 获取视频在线观看人数 */ suspend fun getVideoOnlineTotal( cid: Long, bvid: String? = null, aid: Long? = null ): BiliResponse = client.get("/x/player/online/total") { require(bvid != null || aid != null) { "bvid and aid cannot be null at the same time" } parameter("cid", cid) bvid?.let { parameter("bvid", it) } aid?.let { parameter("aid", it) } }.body() /** * 检查视频[avid]或[bvid]是否已点赞&收藏&投币 */ suspend fun getArchiveRelation( avid: Long? = null, bvid: String? = null, accessKey: String? = null, sessData: String? = null ): BiliResponse { checkToken(accessKey, sessData) val response = client.get("/x/web-interface/archive/relation") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } avid?.let { parameter("aid", it) } bvid?.let { parameter("bvid", it) } accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } } return response.body() } /** * 为视频[avid]或[bvid]点赞或取消赞 * * @param like 是否点赞 * @param csrf bili_jct * @param sessData SESSDATA */ suspend fun sendVideoLike( avid: Long? = null, bvid: String? = null, like: Boolean = true, accessKey: String? = null, csrf: String? = null, sessData: String? = null ): Pair { checkToken(accessKey, sessData) val response = client.post("/x/web-interface/archive/like") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } setBody( FormDataContent( Parameters.build { avid?.let { append("aid", "$it") } bvid?.let { append("bvid", it) } append("like", "${if (like) 1 else 2}") csrf?.let { append("csrf", it) } accessKey?.let { append("access_key", it) } } )) sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() return Pair(response.code == 0, response.message) } /** * 检查视频[avid]或[bvid]是否已点赞 */ suspend fun checkVideoLiked( avid: Long? = null, bvid: String? = null, accessKey: String? = null, sessData: String? = null ): Boolean { checkToken(accessKey, sessData) val response = client.get("/x/web-interface/archive/has/like") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } avid?.let { parameter("aid", it) } bvid?.let { parameter("bvid", it) } accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } } return runCatching { json.decodeFromString>(response.bodyAsText()).getResponseData() == 1 /* response.body>()会找不到序列化器而报错 需要在初始化Json时显示注册序列化器,下面是注册的代码 引入依赖 import kotlinx.serialization.builtins.serializer import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual 给Json增加serializersModule: Json { serializersModule = SerializersModule { // register serializer for BiliResponse directly using reified contextual API contextual>(BiliResponse.serializer(Int.serializer())) } } */ // response.body>().getResponseData() == 1 }.getOrDefault(false) } /** * 为视频[avid]或[bvid]点赞或取消赞 * * @param like 是否顺便点赞 * @param multiply 投币数量 * @param csrf bili_jct * @param sessData SESSDATA */ suspend fun sendVideoCoin( avid: Long? = null, bvid: String? = null, multiply: Int = 1, like: Boolean = false, accessKey: String? = null, csrf: String? = null, sessData: String? = null, buvid3: String? = null ): Pair { checkToken(accessKey, sessData) require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } // val response = client.post("/x/web-interface/coin/add") { val response = client.post("https://app.bilibili.com/x/v2/view/coin/add") { setBody(FormDataContent( Parameters.build { avid?.let { append("aid", "$it") } bvid?.let { append("bvid", it) } append("multiply", "$multiply") append("select_like", "${if (like) 1 else 0}") // csrf?.let { append("csrf", it) } accessKey?.let { append("access_key", it) } } )) // if (sessData != null && buvid3 != null) { // header("Cookie", "SESSDATA=$sessData;buvid3=$buvid3") // } }.body>() return Pair(response.code == 0, response.message) } /** * 检查视频[avid]或[bvid]是否已投币 */ suspend fun checkVideoSentCoin( avid: Long? = null, bvid: String? = null, accessKey: String? = null, sessData: String? = null ): Boolean { checkToken(accessKey, sessData) val response = client.get("/x/web-interface/archive/coins") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } avid?.let { parameter("aid", it) } bvid?.let { parameter("bvid", it) } accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body>() return runCatching { response.getResponseData().multiply != 0 }.getOrDefault(false) } /** * 为视频[avid]添加到[addMediaIds]或从[delMediaIds]移除 */ suspend fun setVideoToFavorite( avid: Long, type: Int = 2, addMediaIds: List = listOf(), delMediaIds: List = listOf(), accessKey: String? = null, csrf: String? = null, sessData: String? = null ) { checkToken(accessKey, sessData) val response = client.post("/x/v3/fav/resource/deal") { require(addMediaIds.isNotEmpty() || delMediaIds.isNotEmpty()) { "addMediaIds and delMediaIds cannot be empty at the same time" } setBody( FormDataContent( Parameters.build { append("rid", "$avid") append("type", "$type") append("add_media_ids", addMediaIds.joinToString(separator = ",")) append("del_media_ids", delMediaIds.joinToString(separator = ",")) csrf?.let { append("csrf", it) } accessKey?.let { append("access_key", it) } } )) sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body>() check(response.code == 0) { response.message } } /** * 检查视频[avid]是否已收藏 */ suspend fun checkVideoFavoured( avid: Long, accessKey: String? = null, sessData: String? = null ): Boolean { checkToken(accessKey, sessData) val response = client.get("/x/v2/fav/video/favoured") { parameter("aid", avid) accessKey?.let { parameter("access_key", it) } sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body>() return runCatching { response.getResponseData().favoured }.getOrDefault(false) } /** * 获取用户[mid]投稿视频 * * @param order 排序方式 默认为pubdate 最新发布:pubdate 最多播放:click 最多收藏:stow * @param tid 筛选目标分区 默认为0 0:不进行分区筛选 分区tid为所筛选的分区 * @param keyword 关键词筛选 用于使用关键词搜索该UP主视频稿件 * @param pageNumber 页码 * @param pageSize 每页项数 最小1,最大50 */ suspend fun getWebUserSpaceVideos( mid: Long, order: String = "pubdate", tid: Int = 0, keyword: String? = null, pageNumber: Int = 1, pageSize: Int = 30, sessData: String, dedeUserID: Long? = null ): BiliResponse = client.get("/x/space/wbi/arc/search") { parameter("mid", mid) parameter("order", order) parameter("tid", tid) keyword?.let { parameter("keyword", it) } parameter("pn", pageNumber) parameter("ps", pageSize) // 风控 parameter("dm_img_list", "[]") parameter("dm_img_str", "V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ") parameter("dm_cover_img_str", "QU5HTEUgKEFNRCwgQU1EIFJhZGVvbiA3ODBNIEdyYXBoaWNzICgweDAwMDAxNUJGKSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzAsIEQzRDExKUdvb2dsZSBJbmMuIChBTU") parameter("dm_img_inter", "{\"ds\":[],\"wh\":[4769,2793,43],\"of\":[285,570,285]}") header("Cookie", "SESSDATA=$sessData;DedeUserID=$dedeUserID;") header("referer", "https://space.bilibili.com") }.body() suspend fun getAppUserSpaceVideos( mid: Long, lastAvid: Long, order: String = "pubdate", ts: Long, accessKey: String ): BiliResponse = client.get("https://app.bilibili.com/x/v2/space/archive/cursor") { parameter("vmid", mid) parameter("aid", lastAvid) parameter("order", order) parameter("ts", ts) parameter("access_key", accessKey) }.body() /** * 获取剧集[seasonId]或[epId]的详细信息 (Web),例如 ss24439 ep234533,传参仅需数字 */ suspend fun getWebSeasonInfo( seasonId: Int? = null, epId: Int? = null, sessData: String = "" ): BiliResponse = client.get("/pgc/view/web/season") { require(seasonId != null || epId != null) { "seasonId and epId cannot be null at the same time" } seasonId?.let { parameter("season_id", it) } epId?.let { parameter("ep_id", it) } header("Cookie", "SESSDATA=$sessData;") //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") }.body() /** * 获取剧集[seasonId]或[epId]的详细信息 (App),例如 ss24439 ep234533,传参仅需数字 */ suspend fun getAppSeasonInfo( seasonId: Int? = null, epId: Int? = null, mobiApp: String, adExtra: String? = null, autoPlay: Int? = null, build: Int? = null, cLocale: String? = null, channel: String? = null, disableRcmd: Int? = null, fromAv: String? = null, fromSpmid: String? = null, isShowAllSeries: Int? = null, platform: String? = null, sLocale: String? = null, spmid: String? = null, statistics: String? = null, trackPath: String? = null, trackid: String? = null, ts: Int? = null, accessKey: String? = "" ): BiliResponse = client.get("/pgc/view/v2/app/season") { require(seasonId != null || epId != null) { "seasonId and epId cannot be null at the same time" } seasonId?.let { parameter("season_id", it) } epId?.let { parameter("ep_id", it) } parameter("mobi_app", mobiApp) adExtra?.let { parameter("ad_extra", it) } autoPlay?.let { parameter("auto_play", it) } build?.let { parameter("build", it) } cLocale?.let { parameter("c_locale", it) } channel?.let { parameter("channel", it) } disableRcmd?.let { parameter("disable_rcmd", it) } fromAv?.let { parameter("from_av", it) } fromSpmid?.let { parameter("from_spmid", it) } isShowAllSeries?.let { parameter("is_show_all_series", it) } platform?.let { parameter("platform", it) } sLocale?.let { parameter("s_locale", it) } spmid?.let { parameter("spmid", it) } statistics?.let { parameter("statistics", it) } trackPath?.let { parameter("track_path", it) } trackid?.let { parameter("trackid", it) } ts?.let { parameter("ts", it) } accessKey?.let { parameter("access_key", accessKey) } }.body() /** * 添加番剧[seasonId]的追番 */ suspend fun addSeasonFollow( seasonId: Int, csrf: String, sessData: String ): BiliResponse = client.post("/pgc/web/follow/add") { setBody( FormDataContent( Parameters.build { append("season_id", "$seasonId") append("csrf", csrf) } )) header("Cookie", "SESSDATA=$sessData;") //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") }.body() /** * 添加番剧[seasonId]的追番 */ suspend fun addSeasonFollow( seasonId: Int, accessKey: String ): BiliResponse = client.post("/pgc/app/follow/add") { setBody( FormDataContent( Parameters.build { append("season_id", "$seasonId") append("access_key", accessKey) } )) }.body() /** * 取消番剧[seasonId]的追番 */ suspend fun delSeasonFollow( seasonId: Int, csrf: String, sessData: String ): BiliResponse = client.post("/pgc/web/follow/del") { setBody( FormDataContent( Parameters.build { append("season_id", "$seasonId") append("csrf", csrf) } )) header("Cookie", "SESSDATA=$sessData;") //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") }.body() /** * 取消番剧[seasonId]的追番 */ suspend fun delSeasonFollow( seasonId: Int, accessKey: String ): BiliResponse = client.post("/pgc/app/follow/del") { setBody( FormDataContent( Parameters.build { append("season_id", "$seasonId") append("access_key", accessKey) } )) }.body() /** * 单独获取剧集[seasonId]的用户信息[WebSeasonData.UserStatus] */ suspend fun getSeasonUserStatus( seasonId: Int, sessData: String ): BiliResponse = client.get("/pgc/view/web/season/user/status") { parameter("season_id", seasonId) header("Cookie", "SESSDATA=$sessData;") //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") }.body() /** * 获取视频[avid]/[bvid]的视频标签[Tag] */ suspend fun getVideoTags( avid: Long? = null, bvid: String? = null, sessData: String = "" ): BiliResponse> = client.get("/x/tag/archive/tags") { require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } avid?.let { parameter("aid", it) } bvid?.let { parameter("bvid", it) } header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取视频标签[tagId]的详细信息,包含相关标签和最新视频 */ suspend fun getTagDetail( tagId: Int, pageNumber: Int, pageSize: Int ): BiliResponse = client.get("/x/tag/detail") { parameter("tag_id", tagId) parameter("pn", pageNumber) parameter("ps", pageSize) }.body() /** * 获取视频标签[tagId]的最热门的视频列表 */ suspend fun getTagTopVideos( tagId: Int, pageNumber: Int, pageSize: Int ): TagTopVideosResponse = client.get("/x/web-interface/tag/top") { parameter("tid", tagId) parameter("pn", pageNumber) parameter("ps", pageSize) }.body() /** * 获取剧集更新时间表 * * @param type 番剧: 1 影视(貌似只有少数几个纪录片): 3, 国创: 4 */ suspend fun getTimeline( type: Int, before: Int, after: Int ): BiliResponse> { val response = client.get("/pgc/web/timeline") { require(before in 0..7) { "before must in [0,7]" } require(after in 0..7) { "after must in [0,7]" } parameter("types", type) parameter("before", before) parameter("after", after) } return runCatching { json.decodeFromString>>(response.bodyAsText()) }.getOrNull() ?: throw IllegalStateException("parse timeline data failed") } /** * 获取剧集更新时间表 * * @param filterType 全部: 0 番剧: 1 我的追番: 2 国创: 3 */ suspend fun getTimeline( filterType: Int, ): BiliResponse = client.get("/pgc/app/timeline") { parameter("filter_type", filterType) parameter("access_key", "") }.body() /** * 获取用户[mid]的关注列表,对于其他用户只能访问前5页 */ suspend fun getUserFollow( mid: Long, orderType: String? = null, pageSize: Int = 50, pageNumber: Int = 1, accessKey: String? = null, sessData: String? = null ): BiliResponse = client.get("/x/relation/followings") { checkToken(accessKey, sessData) parameter("vmid", mid) orderType?.let { parameter("order_type", orderType) } parameter("ps", pageSize) parameter("pn", pageNumber) sessData?.let { header("Cookie", "SESSDATA=$sessData;") } accessKey?.let { parameter("access_key", accessKey) } }.body() /** * 更改与用户[mid]之间的相互关系[action] */ suspend fun modifyFollow( mid: Long, action: FollowAction, actionSource: FollowActionSource, accessKey: String? = null, csrf: String? = null, sessData: String? = null ): BiliResponseWithoutData = client.post("/x/relation/modify") { checkToken(accessKey, sessData) setBody( FormDataContent( Parameters.build { append("fid", "$mid") append("act", "${action.id}") append("re_src", "${actionSource.id}") accessKey?.let { append("access_key", accessKey) } csrf?.let { append("csrf", csrf) } } )) sessData?.let { header("Cookie", "SESSDATA=$sessData;") } }.body() /** * 获取与用户[mid]的相互关系[RelationData] * * 有两个api,响应相同 * - https://api.bilibili.com/x/space/acc/relation * - https://api.bilibili.com/x/web-interface/relation */ suspend fun getRelations( mid: Long, accessKey: String? = null, sessData: String? = null ): BiliResponse = client.get("/x/space/wbi/acc/relation") { checkToken(accessKey, sessData) parameter("mid", mid) accessKey?.let { parameter("access_key", accessKey) } sessData?.let { header("Cookie", "SESSDATA=$sessData;") } }.body() /** * 获取用户[mid]的关系统计(关注数,粉丝数,黑名单数) */ suspend fun getRelationStat( mid: Long, accessKey: String? = null, sessData: String? = null ): BiliResponse = client.get("x/relation/stat") { parameter("vmid", mid) accessKey?.let { parameter("access_key", accessKey) } sessData?.let { header("Cookie", "SESSDATA=$sessData;") } }.body() /** * 获取搜索提示(Web) * * @param limit 返回数量 * @param platform 平台标识 */ suspend fun getWebSearchSquare( limit: Int = 10, platform: String? = null ): BiliResponse = client.get("/x/web-interface/wbi/search/square") { parameter("limit", limit) platform?.let { parameter("platform", platform) } }.body() /** * 获取搜索提示(App) * * @param limit 返回数量,上限仅为 10 * @param platform 平台标识 */ suspend fun getAppSearchSquare( limit: Int = 10, platform: String? = null, //accessKey: String = "" ): BiliResponse> = client.get("https://app.bilibili.com/x/v2/search/square") { parameter("limit", limit) platform?.let { parameter("platform", platform) } parameter("build", BiliAppConf.APP_BUILD_CODE) //parameter("access_key", accessKey) }.body() /** * 获取搜索趋势(App) * * @param limit 返回数量 */ suspend fun getSearchTrendRank( limit: Int = 10 ): BiliResponse = client.get("https://app.bilibili.com/x/v2/search/trending/ranking") { parameter("limit", limit) //platform?.let { parameter("platform", platform) } //parameter("build", BiliAppConf.APP_BUILD_CODE) }.body() /** * 获取搜索关键词建议 * * 如果请求不带 [mainVer],那返回的响应将只会包含 result,但不便于数据处理 * * 如果请求中包含了 [highlight],在返回的结果中 [KeywordSuggest.Result.tag] 的 name 会包含高亮的 html 标签 */ @OptIn(InternalAPI::class) suspend fun getKeywordSuggest( term: String, mainVer: String = "v1", highlight: String? = null, buvid: String ): KeywordSuggest { // 需手动解析 json,因为返回的 Content-Type 为 null,会导致 Ktor 抛出异常 // io.ktor.client.call.NoTransformationFoundException: Expected response body of the type 'class dev.aaa1115910.biliapi.http.entity.search.KeywordSuggest (Kotlin reflection is not available)' but was 'class io.ktor.utils.io.ByteBufferChannel (Kotlin reflection is not available)' // In response from `https://s.search.bilibili.com/main/suggest?term=xxx` // Response status `200 ` // Response header `ContentType: null` // Request header `Accept: application/json` val responseText = client.get("https://s.search.bilibili.com/main/suggest") { parameter("term", term) parameter("main_ver", mainVer) highlight?.let { parameter("highlight", it) } parameter("buvid", buvid) }.readRawBytes().toString(Charsets.UTF_8) val keywordSuggest = json.decodeFromString(responseText) val result = json.decodeFromJsonElement(keywordSuggest.result!!) keywordSuggest.suggests.addAll(result.tag) return keywordSuggest } /** * 综合搜索与[keyword]相关的结果 */ suspend fun searchAll( keyword: String, page: Int = 1, tid: Int? = null, order: String? = null, duration: Int? = null, buvid3: String? = null ): BiliResponse = client.get("/x/web-interface/wbi/search/all/v2") { parameter("keyword", keyword) parameter("page", page) tid?.let { parameter("tids", it) } order?.let { parameter("order", it) } duration?.let { parameter("duration", it) } header("Cookie", "buvid3=$buvid3;") }.body() /** * 分类搜索与[keyword]相关的[type]类型的相关结果 * 必须串行,要等前一个请求完成才能发起下一个请求,否则取不到数据 */ suspend fun searchType( keyword: String, type: String, page: Int = 1, tid: Int? = null, order: String? = null, duration: Int? = null, sessData: String? = null, buvid3: String? = null ): BiliResponse { val response = client.get("/x/web-interface/wbi/search/type") { parameter("keyword", keyword) parameter("search_type", type) parameter("page", page) tid?.let { parameter("tids", it) } order?.let { parameter("order", it) } duration?.let { parameter("duration", it) } if (sessData != null) { header("Cookie", "SESSDATA=$sessData;buvid3=$buvid3;") } else { header("Cookie", "buvid3=$buvid3;") } header("referer", "https://search.bilibili.com/") } return try { response.body() } catch (e: Exception) { val responseText = response.bodyAsText() println("searchType 序列化失败,原始响应内容: $responseText") throw e } } /** 获取番剧首页数据 */ suspend fun getPgcWebInitialStateData(pgcType: PgcType): PgcWebInitialStateData { val path = pgcType.name.lowercase() val htmlDocuments = client.get("https://www.bilibili.com/$path").body() val dataScriptTagContent = htmlDocuments.body().select("script").find { it.html().contains("__INITIAL_STATE__") }?.html() ?: throw IllegalStateException("initial state data cannot be null") val dataJson = dataScriptTagContent.split("__INITIAL_STATE__=", ";(function()")[1] val initinalData = runCatching { json.decodeFromString(dataJson) }.onFailure { println("parse initial state data failed: ${it.stackTraceToString()}") }.getOrNull() ?: throw IllegalStateException("parse initial state data failed") return initinalData } /** * 获取 PGC 猜你喜欢 * * 返回数据的前几条内包含每小时更新的分类排行榜 */ suspend fun getPgcFeedV3( name: String = "anime", cursor: Int = 0 ): BiliResponse = client.get("/pgc/page/web/v3/feed") { parameter("name", name) parameter("coursor", cursor) skipAddBuvid3Cookie() }.body() /** * 获取 PGC 猜你喜欢 */ suspend fun getPgcFeed( name: String = "movie", cursor: Int = 0 ): BiliResponse { val response = client.get("/pgc/page/web/feed") { parameter("name", name) parameter("coursor", cursor) parameter("new_cursor_status", true) skipAddBuvid3Cookie() } return response.body() } /** * 获取用户[mid]的追剧列表 * * @param type 追剧类型 * @param status 追剧状态 * @param pageNumber 页码 * @param pageSize 每页数量 [1, 30] * @param mid 用户id */ suspend fun getFollowingSeasons( type: Int, status: Int, pageNumber: Int = 1, pageSize: Int = 15, mid: Long, sessData: String? = "" ): BiliResponse = client.get("/x/space/bangumi/follow/list") { parameter("type", type) parameter("follow_status", status) parameter("pn", pageNumber) parameter("ps", pageSize) parameter("vmid", mid) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取用户的追剧列表 * * @param type 追剧类型 * @param status 追剧状态 * @param pageNumber 页码 * @param pageSize 每页数量 [1, 30] * @param build App build code */ suspend fun getFollowingSeasons( type: String, status: Int, pageNumber: Int = 1, pageSize: Int = 15, build: Int, accessKey: String ): BiliResponse = client.get("/pgc/app/follow/v2/$type") { parameter("status", status) parameter("pn", pageNumber) parameter("ps", pageSize) parameter("build", build) parameter("access_key", accessKey) }.body() /** * 获取导航栏用户信息 * * 内含 wbi keys */ suspend fun getWebInterfaceNav( buvid3: String? = null, sessData: String = "" ): BiliResponse = client.get("/x/web-interface/nav") { if (buvid3 != null && sessData.isNotEmpty()) { header("Cookie", "buvid3=$buvid3; SESSDATA=$sessData;") } else if (sessData.isNotEmpty()) { header("Cookie", "SESSDATA=$sessData;") } }.body() /** * 风控验证注册 * * 使用 v_voucher 向B站申请 Geetest 验证参数。 * * @param vVoucher 风控返回的 v_voucher 字符串 * @param sessData 用户登录凭证 * @param csrf bili_jct csrf token */ suspend fun gaiaVgateRegister( vVoucher: String, sessData: String? = null, csrf: String? = null ): BiliResponse { val response = client.post("/x/gaia-vgate/v1/register") { csrf?.let { parameter("csrf", it) } setBody( FormDataContent( Parameters.build { append("v_voucher", vVoucher) } ) ) sessData?.let { header("Cookie", "SESSDATA=$it;") } header("referer", "https://www.bilibili.com") } return response.body() } /** * 风控验证校验 * * 提交 Geetest 验证结果,获取 grisk_id 用于后续请求的 gaia_vtoken 参数。 * * @param token 由 [gaiaVgateRegister] 返回的 token * @param geetestChallenge Geetest challenge * @param validate Geetest validate * @param seccode Geetest seccode * @param sessData 用户登录凭证 * @param csrf bili_jct csrf token */ suspend fun gaiaVgateValidate( token: String, geetestChallenge: String, validate: String, seccode: String, sessData: String? = null, csrf: String? = null ): BiliResponse { val response = client.post("/x/gaia-vgate/v1/validate") { csrf?.let { parameter("csrf", it) } setBody( FormDataContent( Parameters.build { append("token", token) append("challenge", geetestChallenge) append("validate", validate) append("seccode", seccode) } ) ) sessData?.let { header("Cookie", "SESSDATA=$it;") } header("referer", "https://www.bilibili.com") } return response.body() } /** * 更新 wbi keys * @param sessData 用户登录凭证,默认使用 sessDataProvider 获取 * @param buvid3 设备标识,默认使用 buvid3Provider 获取 */ suspend fun updateWbi( sessData: String = sessDataProvider(), buvid3: String? = buvid3Provider() ) { val needToUpdate = wbiImgKey == null || wbiSubKey == null || System.currentTimeMillis() - wbiLastRefreshDate < 2 * 60 * 60 * 1000L if (!needToUpdate) { println("Skip update wbi keys") return } println("Updating wbi keys...") runCatching { val wbiData = getWebInterfaceNav(buvid3 = buvid3, sessData = sessData).data!!.wbiImg wbiImgKey = wbiData.getImgKey() wbiSubKey = wbiData.getSubKey() wbiLastRefreshDate = System.currentTimeMillis() }.onSuccess { println("Update wbi data success") }.onFailure { println("Update wbi data failed: ${it.stackTraceToString()}") } } /** * 获取首页视频推荐列表(Web) */ suspend fun getFeedRcmd( freshType: Int = 4, pageSize: Int = 30, idx: Int = 1, buvid3: String? = null, sessData: String? = null ): BiliResponse = client.get("/x/web-interface/wbi/index/top/feed/rcmd") { parameter("fresh_type", freshType) parameter("ps", pageSize) parameter("fresh_idx", idx) parameter("fresh_idx_1h", idx) if (sessData != null && buvid3 != null) { header("Cookie", "buvid3=$buvid3; SESSDATA=$sessData;") } else { sessData?.let { header("Cookie", "SESSDATA=$it;") } } }.body() /** * 获取首页视频推荐列表(App) */ suspend fun getFeedIndex( idx: Int = 0, accessKey: String? = null, ): BiliResponse = client.get("https://app.bilibili.com/x/v2/feed/index") { parameter("idx", idx) accessKey?.let { parameter("access_key", it) } }.body() private suspend fun seasonIndexResult( seasonIndexType: SeasonIndexType, order: Int? = null, seasonVersion: Int? = null, spokenLanguageType: Int? = null, area: Int? = null, isFinish: Int? = null, copyright: Int? = null, seasonStatus: Int? = null, seasonMonth: Int? = null, year: String? = null, releaseDate: String? = null, styleId: Int? = null, producerId: Int? = null, sort: Int? = null, page: Int? = null, pagesize: Int? = null, type: Int? = null ): BiliResponse = client.get("/pgc/season/index/result") { parameter("st", seasonIndexType.id) order?.let { parameter("order", it) } seasonVersion?.let { parameter("season_version", it) } spokenLanguageType?.let { parameter("spoken_language_type", it) } area?.let { parameter("area", it) } isFinish?.let { parameter("is_finish", it) } copyright?.let { parameter("copyright", it) } seasonStatus?.let { parameter("season_status", it) } seasonMonth?.let { parameter("season_month", it) } year?.let { parameter("year", it) } releaseDate?.let { parameter("release_date", it) } styleId?.let { parameter("style_id", it) } producerId?.let { parameter("producer_id", it) } sort?.let { parameter("sort", it) } page?.let { parameter("page", it) } parameter("season_type", seasonIndexType.id) pagesize?.let { parameter("pagesize", it) } type?.let { parameter("type", it) } }.body() suspend fun seasonIndexCondition( seasonIndexType: SeasonIndexType, type: Int = 0 ): BiliResponse = client.get("/pgc/season/index/condition") { parameter("season_type", seasonIndexType.id) parameter("type", type) }.body() suspend fun seasonIndexDynamicResult( seasonIndexType: SeasonIndexType, order: String, sort: String, filters: Map, page: Int = 1, pagesize: Int = 20, type: Int = 0 ): BiliResponse = client.get("/pgc/season/index/result") { parameter("st", seasonIndexType.id) parameter("order", order) parameter("sort", sort) filters.forEach { (field, keyword) -> parameter(field, keyword) } parameter("season_type", seasonIndexType.id) parameter("page", page) parameter("pagesize", pagesize) parameter("type", type) }.body() suspend fun seasonIndexAnimeResult( order: Int = 0, seasonVersion: Int = -1, spokenLanguageType: Int = -1, area: Int = -1, isFinish: Int = -1, copyright: Int = -1, seasonStatus: Int = -1, seasonMonth: Int = -1, year: String = "-1", styleId: Int = -1, sort: Int = 0, page: Int = 1, pagesize: Int = 20, type: Int = 1 ) = seasonIndexResult( seasonIndexType = SeasonIndexType.Anime, order = order, seasonVersion = seasonVersion, spokenLanguageType = spokenLanguageType, area = area, isFinish = isFinish, copyright = copyright, seasonStatus = seasonStatus, seasonMonth = seasonMonth, year = year, styleId = styleId, sort = sort, page = page, pagesize = pagesize, type = type ) suspend fun seasonIndexGuochuangResult( order: Int = 0, seasonVersion: Int = -1, isFinish: Int = -1, copyright: Int = -1, seasonStatus: Int = -1, year: String = "-1", styleId: Int = -1, sort: Int = 0, page: Int = 1, pagesize: Int = 20, type: Int = 1 ) = seasonIndexResult( seasonIndexType = SeasonIndexType.Guochuang, order = order, seasonVersion = seasonVersion, isFinish = isFinish, copyright = copyright, seasonStatus = seasonStatus, year = year, styleId = styleId, sort = sort, page = page, pagesize = pagesize, type = type ) suspend fun seasonIndexVarietyResult( order: Int = 0, seasonStatus: Int = -1, styleId: Int = -1, sort: Int = 0, page: Int = 1, pagesize: Int = 20, type: Int = 1 ) = seasonIndexResult( seasonIndexType = SeasonIndexType.Variety, order = order, seasonStatus = seasonStatus, styleId = styleId, sort = sort, page = page, pagesize = pagesize, type = type ) suspend fun seasonIndexMovieResult( order: Int = 0, area: Int = -1, styleId: Int = -1, releaseDate: String = "-1", seasonStatus: Int = -1, sort: Int = 0, page: Int = 1, pagesize: Int = 20, type: Int = 1 ) = seasonIndexResult( seasonIndexType = SeasonIndexType.Movie, order = order, area = area, styleId = styleId, releaseDate = releaseDate, seasonStatus = seasonStatus, sort = sort, page = page, pagesize = pagesize, type = type ) suspend fun seasonIndexTvResult( order: Int = 0, area: Int = -1, styleId: Int = -1, releaseDate: String = "-1", seasonStatus: Int = -1, sort: Int = 0, page: Int = 1, pagesize: Int = 20, type: Int = 1 ) = seasonIndexResult( seasonIndexType = SeasonIndexType.Tv, order = order, area = area, styleId = styleId, releaseDate = releaseDate, seasonStatus = seasonStatus, sort = sort, page = page, pagesize = pagesize, type = type ) suspend fun seasonIndexDocumentaryResult( order: Int = 0, area: Int = -1, styleId: Int = -1, producerId: Int = -1, releaseDate: String = "-1", seasonStatus: Int = -1, sort: Int = 0, page: Int = 1, pagesize: Int = 20, type: Int = 1 ) = seasonIndexResult( seasonIndexType = SeasonIndexType.Documentary, order = order, area = area, styleId = styleId, producerId = producerId, releaseDate = releaseDate, seasonStatus = seasonStatus, sort = sort, page = page, pagesize = pagesize, type = type ) suspend fun download(url: String): ByteArray { return client.get(url).readRawBytes() } suspend fun getWebVideoShot( aid: Long? = null, bvid: String? = null, cid: Long? = null, needJsonArrayIndex: Boolean = false ): BiliResponse = client.get("/x/player/videoshot") { require(aid != null || bvid != null) { "av and bv cannot be null at the same time" } aid?.let { parameter("aid", it) } bvid?.let { parameter("bvid", it) } cid?.let { parameter("cid", it) } parameter("index", if (needJsonArrayIndex) 1 else 0) }.body() suspend fun getAppVideoShot( aid: Long, cid: Long ): BiliResponse = client.get("https://app.bilibili.com/x/v2/view/video/shot") { parameter("aid", aid) parameter("cid", cid) parameter("ts", 0) }.body() suspend fun getUserEquippedGarb( part: EquipPart, sessData: String ): BiliResponse = client.get("/x/garb/user/equip") { parameter("part", part.value) header("Cookie", "SESSDATA=$sessData;") }.body() /** * 获取分区动态(App),包含顶部轮播图,大卡片活动推广位,和视频列表第一页 */ suspend fun getRegionDynamic( rid: Int, accessKey: String ): BiliResponse = client.get("https://app.bilibili.com/x/v2/region/dynamic") { parameter("access_key", accessKey) parameter("build", BiliAppConf.APP_BUILD_CODE) parameter("rid", rid) }.body() /** * 获取分区视频列表(App),用于[getRegionDynamic]加载数据后下滑加载更多数据 */ suspend fun getRegionDynamicList( rid: Int, ctime: Long = 0, accessKey: String ): BiliResponse = client.get("https://app.bilibili.com/x/v2/region/dynamic/list") { parameter("access_key", accessKey) parameter("build", BiliAppConf.APP_BUILD_CODE) parameter("rid", rid) parameter("ctime", ctime) parameter("pull", "false") }.body() // /** * 获取分区内各种插入的banner,例如顶部轮播图,还有插入的广告横幅(Web) * * id: * 4973 动画 douga * 4991 游戏 game * 5004 鬼畜 kichiku * 4979 音乐 music * 4985 舞蹈 dance * 5008 影视 cinephile * 5007 娱乐 ent * 4997 知识 knowledge * 4998 科技 tech * 5005 资讯 information * 5002 美食 food * 5001 生活 life * 5000 汽车 car * 5006 时尚 fashion * 4999 运动 sports * 5003 动物圈 animal */ suspend fun getLocs( ids: List, sessData: String? = null ): RegionLocs = client.get("/x/web-show/res/locs") { parameter("ids", ids.joinToString(",")) sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() /** * 获取评论 * * @param type 评论类型 * @param oid 评论区id * @param mode 评论排序方式 默认为 3, 0 3:仅按热度 1:按热度+按时间 2:仅按时间 * @param paginationStr 分页参数 */ suspend fun getComments( type: Long, oid: Long, mode: Int = 3, paginationStr: String = """{"offset":""}""", //webLocation: Int = 1815875, sessData: String? = null, dedeUserID: Long? = null, buvid3: String? = null ): BiliResponse = client.get("/x/v2/reply/wbi/main") { parameter("type", type) parameter("oid", oid) parameter("mode", mode) parameter("pagination_str", paginationStr) //parameter("web_location", webLocation) val cookieParts = mutableListOf() sessData?.takeIf { it.isNotBlank() }?.let { cookieParts.add("SESSDATA=$it") } dedeUserID?.let { cookieParts.add("DedeUserID=$it") } buvid3?.takeIf { it.isNotBlank() }?.let { cookieParts.add("buvid3=$it") } if (cookieParts.isNotEmpty()) { header("Cookie", cookieParts.joinToString(";") + ";") } }.body() suspend fun getCommentReplies( oid: Long, type: Long, root: Long, pageSize: Int = 20, pageNumber: Int = 1, sessData: String? = null, dedeUserID: Long? = null, buvid3: String? = null ): BiliResponse { var response = client.get("/x/v2/reply/reply") { parameter("oid", oid) parameter("type", type) parameter("root", root) parameter("ps", pageSize) parameter("pn", pageNumber) val cookieParts = mutableListOf() sessData?.takeIf { it.isNotBlank() }?.let { cookieParts.add("SESSDATA=$it") } dedeUserID?.let { cookieParts.add("DedeUserID=$it") } buvid3?.takeIf { it.isNotBlank() }?.let { cookieParts.add("buvid3=$it") } if (cookieParts.isNotEmpty()) { header("Cookie", cookieParts.joinToString(";") + ";") } } // println(response.bodyAsText()) return response.body() } suspend fun getSeasonIdByAvid( avid: Long ): Int? { return runCatching { val data = getPgcVideoPlayUrlV2(av = avid).getResponseData() data.playViewBusinessInfo.seasonInfo.seasonId }.getOrNull() } suspend fun getAidCidByEpid( epid: Int ): Pair? { return runCatching { val data = getPgcVideoPlayUrlV2(epid = epid).getResponseData() data.playViewBusinessInfo.episodeInfo.aid to data.playViewBusinessInfo.episodeInfo.cid }.getOrNull() } /** * 获取 UGC 分区轮播图 */ suspend fun getRegionBanner( regionId: Int ): BiliResponse = client.get("/x/web-show/region/banner") { parameter("region_id", regionId) }.body() /** * 获取 UGC 分区推荐视频 * * @param displayId 页数 * @param requestCnt 每页数量 * @param fromRegion 分区id */ suspend fun getRegionFeedRcmd( displayId: Int, requestCnt: Int = 15, fromRegion: Int, device: String = "web", plat: Int = 30, sessData: String? = null ): BiliResponse = client.get("/x/web-interface/region/feed/rcmd") { parameter("display_id", displayId) parameter("request_cnt", requestCnt) parameter("from_region", fromRegion) parameter("device", device) parameter("plat", plat) sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() /** * 一键三连 */ suspend fun tripleLike( avid: Long? = null, bvid: String? = null, csrf: String? = null, sessData: String? = null, accessKey: String? = null ): Pair { checkToken(accessKey, sessData) require(avid != null || bvid != null) { "avid and bvid cannot be null at the same time" } // 使用 App API(当只有 accessKey 时) val useAppApi = accessKey != null && sessData == null val url = if (useAppApi) { "https://app.bilibili.com/x/v2/view/like/triple" } else { "/x/web-interface/archive/like/triple" } val response = client.post(url) { setBody( FormDataContent( Parameters.build { avid?.let { append("aid", "$it") } bvid?.let { append("bvid", it) } if (!useAppApi) { csrf?.let { append("csrf", it) } } accessKey?.let { append("access_key", it) } } )) sessData?.let { header("Cookie", "SESSDATA=$it;") } }.body() return Pair(response.code == 0, response.message) } } enum class SeasonIndexType(val id: Int) { Anime(1), Movie(2), Documentary(3), Guochuang(4), Tv(5), Variety(7); companion object { fun fromId(id: Int) = entries.first { it.id == id } } } private fun checkToken(accessKey: String?, sessData: String?) { require(accessKey != null || sessData != null) { "accessKey and sessData cannot be null at the same time" } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/BiliHttpConstants.kt ================================================ package dev.aaa1115910.biliapi.http object BiliHttpConstants { const val USER_AGENT_WEB = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" const val USER_AGENT_APP = "Bilibili Freedoooooom/MarkII" } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/BiliHttpProxyApi.kt ================================================ package dev.aaa1115910.biliapi.http import dev.aaa1115910.biliapi.BiliApiConstants import dev.aaa1115910.biliapi.http.entity.BiliResponse import dev.aaa1115910.biliapi.http.entity.search.SearchResultData import dev.aaa1115910.biliapi.http.entity.video.PlayUrlData import dev.aaa1115910.biliapi.http.entity.video.PlayUrlV2Data import dev.aaa1115910.biliapi.http.plugins.BiliUserAgent import dev.aaa1115910.biliapi.http.util.BiliDns import dev.aaa1115910.biliapi.http.util.encApiSign import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.URLProtocol import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json object BiliHttpProxyApi { private var client: HttpClient? = null private val json = Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true } fun createClient(proxyServer: String) { client = HttpClient(OkHttp) { engine { config { dns(BiliDns) } } BiliUserAgent() install(ContentNegotiation) { json(json) } install(ContentEncoding) { deflate(1.0F) gzip(0.9F) } install(HttpRequestRetry) { retryOnException(maxRetries = 2) } defaultRequest { url { val proxyServerSpilt = proxyServer.split(":") val endPoint = proxyServerSpilt.first() val port = proxyServerSpilt.getOrNull(1)?.toInt() host = endPoint if (endPoint == "127.0.0.1") { //local debug this.port = 8080 } else { if (port != null) { this.port = port } else { protocol = URLProtocol.HTTPS } } } } }.apply { encApiSign() } } suspend fun getPgcVideoPlayUrl( av: Long? = null, bv: String? = null, epid: Int? = null, cid: Long? = null, qn: Int? = null, fnval: Int? = null, fnver: Int? = null, fourk: Int? = null, session: String? = null, supportMultiAudio: Boolean? = null, drmTechType: Int? = null, fromClient: String? = null, sessData: String? = null, dedeUserID: Long? = null, buvid3: String? = null ): BiliResponse = client?.get("/pgc/player/web/playurl") { require(av != null || bv != null) { "av and bv cannot be null at the same time" } require(epid != null || cid != null) { "epid and cid cannot be null at the same time" } av?.let { parameter("avid", it) } bv?.let { parameter("bvid", it) } epid?.let { parameter("ep_id", it) } cid?.let { parameter("cid", it) } qn?.let { parameter("qn", it) } fnval?.let { parameter("fnval", it) } fnver?.let { parameter("fnver", it) } fourk?.let { parameter("fourk", it) } session?.let { parameter("session", it) } supportMultiAudio?.let { parameter("support_multi_audio", it) } drmTechType?.let { parameter("drm_tech_type", it) } fromClient?.let { parameter("from_client", it) } val cookieParts = mutableListOf() sessData?.let { cookieParts.add("SESSDATA=$it") } dedeUserID?.let { cookieParts.add("DedeUserID=$it") } buvid3?.let { cookieParts.add("buvid3=$it") } if (cookieParts.isNotEmpty()) header("Cookie", cookieParts.joinToString(";")) //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") }?.body() ?: throw IllegalStateException("no proxy server") suspend fun getPgcVideoPlayUrlV2( av: Long? = null, bv: String? = null, epid: Int? = null, cid: Long? = null, qn: Int? = null, fnval: Int? = null, fnver: Int? = null, fourk: Int? = null, session: String? = null, supportMultiAudio: Boolean? = null, drmTechType: Int? = null, fromClient: String? = null, sessData: String? = null, buvid3: String? = null ): BiliResponse = client?.get("/pgc/player/web/v2/playurl") { require(av != null || bv != null) { "av and bv cannot be null at the same time" } require(epid != null || cid != null) { "epid and cid cannot be null at the same time" } av?.let { parameter("avid", it) } bv?.let { parameter("bvid", it) } epid?.let { parameter("ep_id", it) } cid?.let { parameter("cid", it) } qn?.let { parameter("qn", it) } fnval?.let { parameter("fnval", it) } fnver?.let { parameter("fnver", it) } fourk?.let { parameter("fourk", it) } session?.let { parameter("session", it) } supportMultiAudio?.let { parameter("support_multi_audio", it) } drmTechType?.let { parameter("drm_tech_type", it) } fromClient?.let { parameter("from_client", it) } val cookieParts = mutableListOf() sessData?.let { cookieParts.add("SESSDATA=$it") } buvid3?.let { cookieParts.add("buvid3=$it") } if (cookieParts.isNotEmpty()) header("Cookie", cookieParts.joinToString(";")) //必须得加上 referer 才能通过账号身份验证 header("referer", "https://www.bilibili.com") }?.body() ?: throw IllegalStateException("no proxy server") /** * 分类搜索与[keyword]相关的[type]类型的相关结果 */ suspend fun searchType( keyword: String, type: String, page: Int = 1, tid: Int? = null, order: String? = null, duration: Int? = null, sessData: String? = null, buvid3: String? = null ): BiliResponse = client?.get("/x/web-interface/wbi/search/type") { parameter("keyword", keyword) parameter("search_type", type) parameter("page", page) tid?.let { parameter("tids", it) } order?.let { parameter("order", it) } duration?.let { parameter("duration", it) } if (sessData != null) { header("Cookie", "SESSDATA=$sessData;buvid3=$buvid3;") } else { header("Cookie", "buvid3=$buvid3;") } header("referer", "https://search.bilibili.com/") }?.body() ?: throw IllegalStateException("no proxy server") } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/BiliLiveHttpApi.kt ================================================ package dev.aaa1115910.biliapi.http import dev.aaa1115910.biliapi.BiliApiConstants import dev.aaa1115910.biliapi.http.entity.BiliResponse import dev.aaa1115910.biliapi.http.entity.live.DanmuInfoData import dev.aaa1115910.biliapi.http.entity.live.HistoryDanmaku import dev.aaa1115910.biliapi.http.entity.live.RoomPlayInfoData import dev.aaa1115910.biliapi.http.plugins.BiliUserAgent import dev.aaa1115910.biliapi.http.util.BiliDns import dev.aaa1115910.biliapi.http.util.encWbi import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.URLProtocol import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json object BiliLiveHttpApi { private var endPoint: String = "" private lateinit var client: HttpClient private val logger = KotlinLogging.logger { } init { createClient() } private fun createClient() { client = HttpClient(OkHttp) { engine { config { dns(BiliDns) } } BiliUserAgent() install(ContentNegotiation) { json(Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true }) } install(ContentEncoding) { deflate(1.0F) gzip(0.9F) } defaultRequest { url { host = "api.live.bilibili.com" protocol = URLProtocol.HTTPS } } } } /** * 获取直播间[roomId]的弹幕连接地址等信息,例如 token * 需要 WBI 签名 * @param sessData 用户登录凭证,传入后可获取已登录用户权限的弹幕 token */ suspend fun getLiveDanmuInfo(roomId: Int, sessData: String = ""): BiliResponse = client.get("/xlive/web-room/v1/index/getDanmuInfo") { parameter("id", roomId) parameter("type", 0) encWbi() if (sessData.isNotEmpty()) header("Cookie", "SESSDATA=$sessData") }.body() /** * 获取直播间[roomId]的信息 */ suspend fun getLiveRoomPlayInfo(roomId: Int): BiliResponse = client.get("/xlive/web-room/v1/index/getRoomPlayInfo") { parameter("room_id", roomId) }.body() /** * 获取直播间[roomId]的历史弹幕 */ suspend fun getLiveDanmuHistory(roomId: Int): BiliResponse = client.get("/xlive/web-room/v1/dM/gethistory") { parameter("roomid", roomId) }.body() } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/BiliPassportHttpApi.kt ================================================ package dev.aaa1115910.biliapi.http import dev.aaa1115910.biliapi.BiliApiConstants import dev.aaa1115910.biliapi.http.entity.BiliResponse import dev.aaa1115910.biliapi.http.entity.login.CaptchaData import dev.aaa1115910.biliapi.http.entity.login.qr.AppQRDataRequest import dev.aaa1115910.biliapi.http.entity.login.qr.AppQRLoginData import dev.aaa1115910.biliapi.http.entity.login.qr.RequestWebQRData import dev.aaa1115910.biliapi.http.entity.login.qr.WebQRLoginData import dev.aaa1115910.biliapi.http.entity.login.sms.SendSmsResponse import dev.aaa1115910.biliapi.http.entity.login.sms.SmsLoginResponse import dev.aaa1115910.biliapi.http.plugins.BiliUserAgent import dev.aaa1115910.biliapi.http.util.BiliDns import dev.aaa1115910.biliapi.http.util.encApiSign import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.forms.FormDataContent import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText import io.ktor.http.Cookie import io.ktor.http.Parameters import io.ktor.http.URLProtocol import io.ktor.http.setCookie import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json object BiliPassportHttpApi { private lateinit var client: HttpClient private val json = Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true } init { createClient() } private fun createClient() { client = HttpClient(OkHttp) { engine { config { dns(BiliDns) } } BiliUserAgent() install(ContentNegotiation) { json(Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true }) } install(ContentEncoding) { deflate(1.0F) gzip(0.9F) } defaultRequest { url { host = "passport.bilibili.com" protocol = URLProtocol.HTTPS } } }.apply { encApiSign() } } /** * 申请二维码(Web) */ suspend fun getWebQRUrl(): BiliResponse = client.get("/x/passport-login/web/qrcode/generate").body() /** * 使用[qrcodeKey]进行二维码登录 */ suspend fun loginWithWebQR(qrcodeKey: String): Pair, List> { val loginResponse = client.get("/x/passport-login/web/qrcode/poll") { parameter("qrcode_key", qrcodeKey) } return Pair(loginResponse.body(), loginResponse.setCookie()) } /** * 申请二维码(App) */ suspend fun getAppQRUrl( localId: String? = null, ts: Int, mobiApp: String? = null ): BiliResponse = client.post("/x/passport-tv-login/qrcode/auth_code") { setBody(FormDataContent( Parameters.build { localId?.let { append("local_id", it) } append("ts", "$ts") mobiApp?.let { append("mobi_app", it) } } )) }.body() /** * 使用[authCode]进行二维码登录 */ suspend fun loginWithAppQR( authCode: String, localId: String? = null, ts: Int ): BiliResponse = client.post("/x/passport-tv-login/qrcode/poll") { setBody(FormDataContent( Parameters.build { append("auth_code", authCode) localId?.let { append("local_id", it) } append("ts", "$ts") } )) }.body() /** * 申请 captcha 验证码 * * @param source 获取来源 已知:main_web */ suspend fun getCaptcha( source: String? = null ): BiliResponse = client.get("/x/passport-login/captcha") { source?.let { parameter("source", it) } }.body() /** * 发送短信验证码 * * @param cid 国际冠字码 * @param tel 手机号码 * @param loginSessionId 登录标识 uuid去掉'-'后得到 * @param channel 一般固定值为"bili" * @param buvid * @param statistics 一般固定为{"appId":1,"platform":3,"version":"7.27.0","abtest":""} */ suspend fun sendSms( cid: Long, tel: Long, loginSessionId: String, recaptchaToken: String? = null, geeChallenge: String? = null, geeValidate: String? = null, geeSeccode: String? = null, channel: String, buvid: String, statistics: String, ts: Long ): BiliResponse = client.post("/x/passport-login/sms/send") { setBody(FormDataContent( Parameters.build { append("cid", "$cid") append("tel", "$tel") append("login_session_id", loginSessionId) recaptchaToken?.let { append("recaptcha_token", it) } geeChallenge?.let { append("gee_challenge", it) } geeValidate?.let { append("gee_validate", it) } geeSeccode?.let { append("gee_seccode", it) } append("channel", channel) append("buvid", buvid) append("statistics", statistics) append("ts", "$ts") } )) }.body() suspend fun loginWithSms( cid: Long, tel: Long, loginSessionId: String, code: Int, captchaKey: String ): BiliResponse = client.post("/x/passport-login/login/sms") { setBody(FormDataContent( Parameters.build { append("cid", "$cid") append("tel", "$tel") append("login_session_id", loginSessionId) append("code", "$code") append("captcha_key", captchaKey) append("ts", "0") } )) }.body() /** * 获取buvid3 * * @param source 获取来源 已知:main_web */ suspend fun getbuvid3(): String { val response = client.get("/x/web-frontend/getbuvid") { url{ host = "api.bilibili.com" protocol = URLProtocol.HTTPS } } return runCatching { json.decodeFromString>(response.bodyAsText()).getResponseData() }.getOrDefault("") } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/BiliPlusHttpApi.kt ================================================ package dev.aaa1115910.biliapi.http import dev.aaa1115910.biliapi.BiliApiConstants import dev.aaa1115910.biliapi.http.entity.BiliResponse import dev.aaa1115910.biliapi.http.entity.biliplus.View import dev.aaa1115910.biliapi.http.plugins.BiliUserAgent import dev.aaa1115910.biliapi.http.util.BiliDns import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpRequestRetry import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.statement.bodyAsText import io.ktor.http.URLProtocol import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive object BiliPlusHttpApi { private var endPoint: String = "www.biliplus.com" private lateinit var client: HttpClient private val json = Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true } init { createClient() } private fun createClient() { client = HttpClient(OkHttp) { engine { config { dns(BiliDns) } } BiliUserAgent() install(ContentNegotiation) { json(json) } install(ContentEncoding) { deflate(1.0F) gzip(0.9F) } install(HttpRequestRetry) { retryOnException(maxRetries = 2) } defaultRequest { url { host = endPoint protocol = URLProtocol.HTTPS } } } } suspend fun view( aid: Long, update: Boolean = true, accessKey: String? = null ): BiliResponse { val result = client.get("/api/view") { parameter("id", aid) parameter("update", update) accessKey?.let { parameter("access_key", it) } }.bodyAsText() val resultJsonObject = json.parseToJsonElement(result).jsonObject return if (resultJsonObject.size == 3) { BiliResponse( code = resultJsonObject["code"]!!.jsonPrimitive.int, message = resultJsonObject["message"]!!.jsonPrimitive.content, ttl = resultJsonObject["ttl"]!!.jsonPrimitive.int, data = null ) } else { BiliResponse( code = 0, message = "success", ttl = 0, result = json.decodeFromJsonElement(resultJsonObject) ) } } suspend fun getSeasonIdByAvid( aid: Long ): Int? { return runCatching { view(aid).getResponseData().bangumi?.seasonId?.toInt() }.onFailure { println("get season id by avid through biliplus failed: ${it.stackTraceToString()}") }.getOrDefault(null) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/BiliResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.serialization.Serializable /** * @param code 0:成功 -101:账号未登录 -400:参数错误 -401:非法访问 -403:访问权限不足 */ @Serializable data class BiliResponse( val code: Int, val message: String, val ttl: Int? = null, val data: T? = null, val result: T? = null ) { companion object { private val logger = KotlinLogging.logger {} } init { when (code) { 0 -> {} -101 -> logger.error { "请求失败,账号未登录: $message (code: $code)" } -352 -> logger.error { "请求失败,风控异常: $message (code: $code)" } else -> logger.error { "请求失败: $message (code: $code)" } } } @Throws() fun getResponseData(): T { when (code) { 0 -> {} -101 -> throw AuthFailureException(message) -352 -> throw RiskControlException(message) 87008 -> throw IllegalStateException("该视频为专属视频,需要充电才能观看 (code: $code)") else -> throw IllegalStateException(message) } check(data != null || result != null) { "response data and result are both null" } data?.let { return it } result?.let { return it } error("response data and result are both null, and code should not run here") } } @Serializable data class BiliResponseWithoutData( val code: Int, val message: String, val ttl: Int ) { companion object { private val logger = KotlinLogging.logger {} } init { when (code) { 0 -> {} -101 -> logger.error { "请求失败,账号未登录: $message (code: $code)" } -352 -> logger.error { "请求失败,风控异常: $message (code: $code)" } else -> logger.error { "请求失败: $message (code: $code)" } } } } @Suppress("unused") class AuthFailureException : RuntimeException { constructor() : super() constructor(message: String?) : super(message) constructor(message: String?, cause: Throwable?) : super(message, cause) constructor(cause: Throwable?) : super(cause) } @Suppress("unused") class RiskControlException : RuntimeException { constructor() : super() constructor(message: String?) : super(message) constructor(message: String?, cause: Throwable?) : super(message, cause) constructor(cause: Throwable?) : super(cause) } /** * 风控 v_voucher 异常 * * 当 API 返回 code=0 但 data 中仅包含 v_voucher 时抛出, * 需要通过 Geetest 验证后使用返回的 grisk_id 作为 gaia_vtoken 重试请求。 */ class VVoucherException(val vVoucher: String) : RuntimeException("risk control v_voucher: $vVoucher") ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/biliplus/View.kt ================================================ package dev.aaa1115910.biliapi.http.entity.biliplus import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @Serializable data class View( val aid: Long, val author: String, val bangumi: Bangumi? = null, val coins: Int, val created: Int, @SerialName("created_at") val createdAt: String, val description: String, val favorites: Int, val id: Int, @SerialName("lastupdate") val lastUpdate: String, @SerialName("lastupdatets") val lastUpdatets: Int, val list: List, val mid: Long, val pic: String, val play: Long, val review: Int, val tag: String, val tid: Int, val title: String, val typename: String, @SerialName("v2_app_api") val v2AppApi: V2AppApi, val ver: Int, @SerialName("video_review") val videoReview: Int ) { @Serializable data class Bangumi( val cover: String, @SerialName("is_finish") val isFinish: String, @SerialName("is_jump") val isJump: Int, @SerialName("newest_ep_id") val newestEpId: String, @SerialName("newest_ep_index") val newestEpIndex: String, @SerialName("ogv_play_url") val ogvPlayUrl: String, @SerialName("season_id") val seasonId: String, val title: String, @SerialName("total_count") val totalCount: String, val weekday: String ) @Serializable data class ListItem( val cid: Long, val page: Int, val part: String, val type: String, val vid: String ) @Serializable data class V2AppApi( val aid: Long, val bvid: String, val cid: Long, @SerialName("cm_config") val cmConfig: CmConfig, val config: Config, val copyright: Int, val ctime: Int, @SerialName("DagwUser") val dagwUser: List, @SerialName("DataCenterInfo") val dataCenterInfo: String, val desc: String, val dimension: Dimension, @SerialName("dm_seg") val dmSeg: Int, val duration: Int, val `dynamic`: String, @SerialName("InteractLabel") val interactLabel: String, @SerialName("like_custom") val likeCustom: LikeCustom, @SerialName("LiveOrderText") val liveOrderText: String, val owner: Owner, @SerialName("owner_ext") val ownerExt: OwnerExt, val pages: List, val paster: Paster? = null, val pic: String, @SerialName("play_param") val playParam: Int, @SerialName("PlayToast") val playToast: JsonElement? = null, @SerialName("premiere_resource") val premiereResource: JsonElement? = null, @SerialName("pub_location") val pubLocation: String? = null, val pubdate: Int, @SerialName("redirect_url") val redirectUrl: String? = null, @SerialName("RejectPage") val rejectPage: JsonElement? = null, val rights: Rights, val season: Season? = null, @SerialName("share_subtitle") val shareSubtitle: String? = null, @SerialName("short_link") val shortLink: String, @SerialName("short_link_v2") val shortLinkV2: String, val stat: Stat, val state: Int, @SerialName("t_icon") val tIcon: TIcon, @SerialName("TabModule") val tabModule: JsonElement? = null, val tag: List, val tid: Int, val title: String, val tname: String, @SerialName("up_from_v2") val upFromV2: Int? = null, val videos: Int, @SerialName("vt_display") val vtDisplay: String ) { @Serializable data class CmConfig( @SerialName("ads_control") val adsControl: AdsControl ) { @Serializable data class AdsControl( @SerialName("has_danmu") val hasDanmu: Int, @SerialName("under_player_scroller_seconds") val underPlayerScrollerSeconds: Int ) } @Serializable data class Config( @SerialName("abtest_small_window") val abtestSmallWindow: String, @SerialName("feed_has_next") val feedHasNext: Boolean, @SerialName("feed_style") val feedStyle: String, @SerialName("has_guide") val hasGuide: Boolean, @SerialName("is_absolute_time") val isAbsoluteTime: Boolean, @SerialName("local_play") val localPlay: Int, @SerialName("rec_three_point_style") val recThreePointStyle: Int, @SerialName("relates_title") val relatesTitle: String, @SerialName("share_style") val shareStyle: Int, @SerialName("valid_show_m") val validShowM: Int, @SerialName("valid_show_n") val validShowN: Int ) @Serializable data class Dimension( val height: Int, val rotate: Int, val width: Int ) @Serializable data class LikeCustom( @SerialName("full_to_half_progress") val fullToHalfProgress: Int, @SerialName("like_switch") val likeSwitch: Boolean, @SerialName("non_full_progress") val nonFullProgress: Int, @SerialName("update_count") val updateCount: Int ) @Serializable data class Owner( val face: String, val mid: Int, val name: String ) @Serializable data class OwnerExt( @SerialName("arc_count") val arcCount: String, val assists: JsonElement? = null, val fans: Int, @SerialName("nft_face_icon") val nftFaceIcon: JsonElement? = null, @SerialName("official_verify") val officialVerify: OfficialVerify, val vip: Vip ) { @Serializable data class OfficialVerify( val desc: String, val type: Int ) @Serializable data class Vip( val accessStatus: Int, val dueRemark: String, val label: Label, val themeType: Int, val vipDueDate: Long, val vipStatus: Int, val vipStatusWarn: String, val vipType: Int ) { @Serializable data class Label( @SerialName("bg_color") val bgColor: String, @SerialName("bg_style") val bgStyle: Int, @SerialName("border_color") val borderColor: String, @SerialName("img_label_uri_hans") val imgLabelUriHans: String, @SerialName("img_label_uri_hans_static") val imgLabelUriHansStatic: String, @SerialName("img_label_uri_hant") val imgLabelUriHant: String, @SerialName("img_label_uri_hant_static") val imgLabelUriHantStatic: String, @SerialName("label_theme") val labelTheme: String, val path: String, val text: String, @SerialName("text_color") val textColor: String, @SerialName("use_img_label") val useImgLabel: Boolean ) } } @Serializable data class Page( val cid: Long, val dimension: Dimension? = null, val dmlink: String? = null, @SerialName("download_subtitle") val downloadSubtitle: String? = null, @SerialName("download_title") val downloadTitle: String? = null, val duration: Int? = null, val from: String, val page: Int, val part: String, val vid: String, val weblink: String? = null ) { @Serializable data class Dimension( val height: Int, val rotate: Int, val width: Int ) } @Serializable data class Paster( val aid: Long, @SerialName("allow_jump") val allowJump: Int, val cid: Long, val duration: Int, val type: Int, val url: String ) @Serializable data class Rights( @SerialName("arc_pay") val arcPay: Int, val autoplay: Int, val bp: Int, val download: Int, val elec: Int, val hd5: Int, @SerialName("is_cooperation") val isCooperation: Int, val movie: Int, @SerialName("no_background") val noBackground: Int, @SerialName("no_reprint") val noReprint: Int, val pay: Int, @SerialName("pay_free_watch") val payFreeWatch: Int, @SerialName("ugc_pay") val ugcPay: Int, @SerialName("ugc_pay_preview") val ugcPayPreview: Int ) @Serializable data class Season( val cover: String, @SerialName("is_finish") val isFinish: String, @SerialName("is_jump") val isJump: Int, @SerialName("newest_ep_id") val newestEpId: String, @SerialName("newest_ep_index") val newestEpIndex: String, @SerialName("ogv_play_url") val ogvPlayUrl: String, @SerialName("season_id") val seasonId: String, val title: String, @SerialName("total_count") val totalCount: String, val weekday: String ) @Serializable data class Stat( val aid: Long, val coin: Int, val danmaku: Int, val dislike: Int, val favorite: Int, @SerialName("his_rank") val hisRank: Int, val like: Int, @SerialName("now_rank") val nowRank: Int, val reply: Int, val share: Int, val view: Long, val vt: Int, val vv: Int ) @Serializable data class TIcon( val act: Act, val new: New ) { @Serializable data class Act( val icon: String ) @Serializable data class New( val icon: String ) } @Serializable data class Tag( val attribute: Int, val cover: String, val hated: Int, val hates: Int, @SerialName("is_activity") val isActivity: Int, val liked: Int, val likes: Int, @SerialName("tag_id") val tagId: Int, @SerialName("tag_name") val tagName: String, @SerialName("tag_type") val tagType: String, val uri: String ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/danmaku/DanmakuResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity.danmaku data class DanmakuResponse( val chatserver: String, val chatId: Long, val maxLimit: Int, val state: Int, val realName: Int, val source: String, val data: List = emptyList() ) data class DanmakuData( val time: Float, val type: Int, val size: Int, val color: Int, val timestamp: Int, val pool: Int, val midHash: String, val dmid: Long, val level: Int = 0, val text: String ) { companion object { fun fromString(p: String, text: String): DanmakuData { val data = p.split(",") if (data.size < 9) { throw IllegalArgumentException("Invalid danmaku data format: insufficient parameters") } return DanmakuData( time = data[0].toFloat(), type = data[1].toInt(), size = data[2].toInt(), color = data[3].toInt(), timestamp = data[4].toInt(), pool = data[5].toInt(), midHash = data[6], dmid = data[7].toLong(), level = data[8].toInt(), text = text ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/dynamic/DynamicDetailResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity.dynamic import kotlinx.serialization.Serializable @Serializable data class DynamicDetailData( val item: DynamicItem ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/dynamic/DynamicResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity.dynamic import dev.aaa1115910.biliapi.http.entity.user.Pendant import dev.aaa1115910.biliapi.http.entity.user.Vip import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class DynamicData( @SerialName("has_more") val hasMore: Boolean, val offset: String, @SerialName("update_baseline") val updateBaseline: String, @SerialName("update_num") val updateNum: Int, val items: List = emptyList() ) /** * @param basic 基础信息 * @param idStr 动态 id * @param modules 动态内容 * @param orig 转发内容 * @param type 动态类型 * @param visible 是否可见 */ @Serializable data class DynamicItem( val basic: Basic, @SerialName("id_str") val idStr: String? = null, val modules: Modules, val orig: DynamicItem? = null, val type: String, val visible: Boolean, @SerialName("jump_url") val jumpUrl: String? = null ) { @Serializable data class Basic( @SerialName("comment_id_str") val commentIdStr: String, @SerialName("comment_type") val commentType: Long, @SerialName("jump_url") val jumpUrl: String? = null, @SerialName("like_icon") val likeIcon: LikeIcon, @SerialName("rid_str") val ridStr: String ) { @Serializable data class LikeIcon( @SerialName("action_url") val actionUrl: String, @SerialName("end_url") val endUrl: String, val id: Long, @SerialName("start_url") val startUrl: String ) } /** * @param moduleAuthor 作者信息 * @param moduleDynamic 动态内容 * @param moduleMore 更多菜单按钮信息 当位于转发内容 [DynamicItem.orig] 时,该项为 null * @param moduleStat 动态底部按钮信息 当位于转发内容 [DynamicItem.orig] 时,该项为 null */ @Serializable data class Modules( @SerialName("module_author") val moduleAuthor: Author, @SerialName("module_dynamic") val moduleDynamic: Dynamic, @SerialName("module_more") val moduleMore: More? = null, @SerialName("module_stat") val moduleStat: Stat? = null ) { @Serializable data class Author( val face: String, @SerialName("face_nft") val faceNft: Boolean, val following: Boolean = false, @SerialName("jump_url") val jumpUrl: String, val label: String, val mid: Long, val name: String, @SerialName("official_verify") val officialVerify: OfficialVerify? = null, val pendant: Pendant? = null, @SerialName("pub_action") val pubAction: String, @SerialName("pub_location_text") val pubLocationText: String? = null, @SerialName("pub_time") val pubTime: String, @SerialName("pub_ts") val pubTs: Int, val type: String, val vip: Vip? = null ) { @Serializable data class OfficialVerify( val desc: String, val type: Int ) } @Serializable data class Dynamic( val additional: Additional? = null, val desc: Desc? = null, val major: Major? = null, val topic: Topic? = null ) { @Serializable data class Additional( val common: Common? = null, val reserve: Reserve? = null, val type: String ) { @Serializable data class Common( val button: Button, val cover: String, val desc1: String, val desc2: String, @SerialName("head_text") val headText: String, @SerialName("id_str") val idStr: String, @SerialName("jump_url") val jumpUrl: String, val style: Int, @SerialName("sub_type") val subType: String, val title: String ) } @Serializable data class Button( val check: ButtonItem? = null, val status: Int? = null, val type: Int, val uncheck: ButtonItem? = null, @SerialName("jump_style") val jumpStyle: ButtonItem? = null, @SerialName("jump_url") val jumpUrl: String? = null ) { @Serializable data class ButtonItem( @SerialName("icon_url") val iconUrl: String? = null, val text: String ) } @Serializable data class Reserve( val button: Button, val desc1: Desc, val desc2: Desc, @SerialName("jump_url") val jumpUrl: String, @SerialName("reserve_total") val reserveTotal: Int, val rid: Long, val state: Int, val stypc: Int? = null, val title: String, @SerialName("up_mid") val upMid: Long ) { @Serializable data class Desc( val style: Int, val text: String, val visible: Boolean? = null ) } @Serializable data class Desc( @SerialName("rich_text_nodes") val richTextNodes: List, val text: String ) { @Serializable data class RichTextNodeItem( val emoji: Emoji? = null, @SerialName("orig_text") val origText: String, val text: String, val type: String ) { @Serializable data class Emoji( @SerialName("icon_url") val iconUrl: String, val size: Int, val text: String, val type: Int ) } } /** * 在一些情况下会出现数据存放的位置不一样的情况 * 例如默认情况下 draw 的文字会放在上一次级类的 desc 中,而浏览器里默认在请求时会带上一些 features,使内容放在 opus 内 */ @Serializable data class Major( val archive: Archive? = null, @SerialName("live_rcmd") val liveRcmd: LiveRcmd? = null, val opus: Opus? = null, val draw: Draw? = null, val pgc: Pgc? = null, val article: Article? = null, val none: None? = null, @SerialName("ugc_season") val ugcSeason: UgcSeason? = null, val type: String ) { @Serializable data class Archive( val aid: String, val badge: Badge, val bvid: String, val cover: String, val desc: String, @SerialName("disable_preview") val disablePreview: Int, @SerialName("duration_text") val durationText: String, @SerialName("jump_url") val jumpUrl: String, val stat: Stat, val title: String, val type: Int ) { @Serializable data class Badge( @SerialName("bg_color") val bgColor: String, val color: String, val text: String ) @Serializable data class Stat( val danmaku: String, val play: String ) } @Serializable data class LiveRcmd( val content: String, @SerialName("reserve_type") val reserveType: Int ) /** * @param foldAction [展开,收起] * @param jumpUrl 跳转地址 * @param pics 动态内的图片 * @param summary 动态内的文字 */ @Serializable data class Opus( @SerialName("fold_action") val foldAction: List, @SerialName("jump_url") val jumpUrl: String, val pics: List, val summary: Desc, val title: String? = null ) { @Serializable data class Pic( val height: Int, val width: Int, val size: Float? = null, val url: String ) } @Serializable data class Draw( val id: Int, val items: List, ) { @Serializable data class Pic( val height: Int, val width: Int, val size: Float? = null, val src: String, val tags: List ) } @Serializable data class Pgc( val badge: Archive.Badge, val cover: String, val epid: Int, @SerialName("jump_url") val jumpUrl: String, @SerialName("season_id") val seasonId: Int, val stat: Archive.Stat, @SerialName("sub_type") val subType: Int, val title: String, val type: Int ) @Serializable data class Article( val covers: List, val desc: String, val id: Int, @SerialName("jump_url") val jumpUrl: String, val label: String, val title: String ) @Serializable data class None( val tips: String ) @Serializable data class UgcSeason( val aid: Long, val badge: Archive.Badge, val bvid: String, val cover: String, val desc: String? = null, @SerialName("disable_preview") val disablePreview: Int, @SerialName("duration_text") val durationText: String, @SerialName("jump_url") val jumpUrl: String, val stat: Archive.Stat, val title: String, val type: Int ) } @Serializable data class Topic( val id: Int, @SerialName("jump_url") val jumpUrl: String, val name: String ) } @Serializable data class More( @SerialName("three_point_items") val threePointItems: List = emptyList() ) { @Serializable data class MoreItem( val label: String, val type: String ) } @Serializable data class Stat( val comment: StatItem, val forward: StatItem, val like: StatItem ) { @Serializable data class StatItem( val count: Int, val forbidden: Boolean, val statue: Boolean = false ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/history/HistoryData.kt ================================================ package dev.aaa1115910.biliapi.http.entity.history import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * 历史记录信息 * * @param cursor 历史记录页面信息 * @param tab 历史记录筛选类型 * @param list 分段历史记录列表 */ @Serializable data class HistoryData( val cursor: Cursor, val tab: List, val list: List ) { /** * 历史记录页面信息 * * @param max 最后一项目标 id 见请求参数 * @param viewAt 最后一项时间节点 时间戳 * @param business 最后一项业务类型 见请求参数 * @param ps 每页项数 */ @Serializable data class Cursor( val max: Long, @SerialName("view_at") val viewAt: Long, val business: String, val ps: Int ) /** * 历史记录筛选类型 * * @param type 类型 * @param name 类型名 */ @Serializable data class TabItem( val type: String, val name: String ) } /** * 历史记录列表项 * * @param title 条目标题 * @param longTitle 条目副标题 * @param cover 条目封面图 url 用于专栏以外的条目 * @param covers 条目封面图组 仅用于专栏 有效时:array 无效时:null * @param uri 重定向 url 仅用于剧集和直播 * @param history 条目详细信息 * @param videos 视频分 P 数目 仅用于稿件视频 * @param authorName UP 主昵称 * @param authorFace UP 主头像 url * @param authorMid UP 主 mid * @param viewAt 查看时间 时间戳 * @param progress 视频观看进度 单位为秒 用于稿件视频或剧集 * @param badge 角标文案 稿件视频 / 剧集 / 笔记 * @param showTitle 分 P 标题 用于稿件视频或剧集 * @param duration 视频总时长 用于稿件视频或剧集 * @param current (?) * @param total 总计分集数 仅用于剧集 * @param newDesc 最新一话 / 最新一 P 标识 用于稿件视频或剧集 * @param isFinish 是否已完结 仅用于剧集 0:未完结 1:已完结 * @param isFav 是否收藏 0:未收藏 1:已收藏 * @param kid 条目目标 id 详细内容见参数 * @param tagName 子分区名 用于稿件视频和直播 * @param liveStatus 直播状态 仅用于直播 0:未开播 1:已开播 */ @Serializable data class HistoryItem( val title: String, @SerialName("long_title") val longTitle: String, val cover: String, val covers: List? = null, val uri: String, val history: HistoryInfo, val videos: Int, @SerialName("author_name") val authorName: String, @SerialName("author_face") val authorFace: String, @SerialName("author_mid") val authorMid: Long, @SerialName("view_at") val viewAt: Long, val progress: Int, val badge: String, @SerialName("show_title") val showTitle: String, val duration: Int, val current: String, val total: Int, @SerialName("new_desc") val newDesc: String, @SerialName("is_finish") val isFinish: Int, @SerialName("is_fav") val isFav: Int, val kid: Long, @SerialName("tag_name") val tagName: String, @SerialName("live_status") val liveStatus: Int ) { /** * 历史记录详细信息 * * @param oid 目标id 稿件视频&剧集(当business=archive或business=pgc时):稿件avid 直播(当business=live时):直播间id 文章(当business=article时):文章cvid 文集(当business=article-list时):文集rlid * @param epid 剧集epid 仅用于剧集 * @param bvid 稿件bvid 仅用于稿件视频 * @param page 观看到的视频分P数 仅用于稿件视频 * @param cid 观看到的对象id 稿件视频&剧集(当business=archive或business=pgc时):视频cid 文集(当business=article-list时):文章cvid * @param part 观看到的视频分 P 标题 仅用于稿件视频 * @param business 业务类型 见请求参数 * @param dt 记录查看的平台代码 1 3 5 7:手机端 2:web端 4 6:pad端 33:TV端 0:其他 */ @Serializable data class HistoryInfo( val oid: Long, val epid: Int, val bvid: String, val page: Int, val cid: Long, val part: String, val business: String, val dt: Int ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/home/RcmdIndexData.kt ================================================ package dev.aaa1115910.biliapi.http.entity.home import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @Serializable data class RcmdIndexData( val config: Config, @SerialName("interest_choose") val interestChoose: JsonElement? = null, val items: List ) { @Serializable data class Config( @SerialName("auto_refresh_by_behavior") val autoRefreshByBehavior: Int? = null, @SerialName("auto_refresh_time") val autoRefreshTime: Int, @SerialName("auto_refresh_time_by_active") val autoRefreshTimeByActive: Int, @SerialName("auto_refresh_time_by_appear") val autoRefreshTimeByAppear: Int, @SerialName("auto_refresh_time_by_behavior") val autoRefreshTimeByBehavior: Int? = null, @SerialName("autoplay_card") val autoplayCard: Int, @SerialName("card_density_exp") val cardDensityExp: Int, val column: Int, @SerialName("enable_rcmd_guide") val enableRcmdGuide: Boolean, @SerialName("feed_clean_abtest") val feedCleanAbtest: Int, @SerialName("history_cache_size") val historyCacheSize: Int? = null, @SerialName("home_transfer_test") val homeTransferTest: Int, @SerialName("inline_sound") val inlineSound: Int, @SerialName("is_back_to_homepage") val isBackToHomepage: Boolean, @SerialName("show_inline_danmaku") val showInlineDanmaku: Int, @SerialName("single_autoplay_flag") val singleAutoplayFlag: Int? = null, @SerialName("small_cover_wh_ratio") val smallCoverWhRatio: Double? = null, @SerialName("space_enlarge_exp") val spaceEnlargeExp: Int? = null, @SerialName("story_mode_v2_guide_exp") val storyModeV2GuideExp: Int, val toast: JsonElement, @SerialName("trigger_loadmore_left_line_num") val triggerLoadmoreLeftLineNum: Int? = null, @SerialName("video_mode") val videoMode: Int? = null, @SerialName("visible_area") val visibleArea: Int ) @Serializable data class RcmdItem( //@SerialName("ad_info") //val adInfo: AdInfo, val args: Args, @SerialName("can_play") val canPlay: Int? = null, @SerialName("card_goto") val cardGoto: String, @SerialName("card_type") val cardType: String, val cover: String? = null, @SerialName("cover_left_1_content_description") val coverLeft1ContentDescription: String? = null, @SerialName("cover_left_2_content_description") val coverLeft2ContentDescription: String? = null, @SerialName("cover_left_icon_1") val coverLeftIcon1: Int? = null, @SerialName("cover_left_icon_2") val coverLeftIcon2: Int? = null, @SerialName("cover_left_text_1") val coverLeftText1: String? = null, @SerialName("cover_left_text_2") val coverLeftText2: String? = null, @SerialName("cover_right_content_description") val coverRightContentDescription: String? = null, @SerialName("cover_right_text") val coverRightText: String? = null, val desc: String? = null, @SerialName("desc_button") val descButton: DescButton? = null, @SerialName("ff_cover") val ffCover: String? = null, val goto: String? = null, @SerialName("goto_icon") val gotoIcon: GotoIcon? = null, val idx: Int, @SerialName("official_icon") val officialIcon: Int? = null, @SerialName("param") val `param`: String? = null, @SerialName("player_args") val playerArgs: PlayerArgs? = null, @SerialName("rcmd_reason") val rcmdReason: String? = null, @SerialName("rcmd_reason_style") val rcmdReasonStyle: RcmdReasonStyle? = null, @SerialName("report_flow_data") val reportFlowData: String? = null, @SerialName("talk_back") val talkBack: String? = null, @SerialName("three_point") val threePoint: ThreePoint? = null, @SerialName("three_point_v2") val threePointV2: List, val title: String? = null, @SerialName("track_id") val trackId: String? = null, val uri: String? = null ) { @Serializable data class Args( val aid: Long? = null, val rid: Int? = null, val rname: String? = null, val tid: Int? = null, val tname: String? = null, @SerialName("up_id") val upId: Long? = null, @SerialName("up_name") val upName: String? = null ) @Serializable data class DescButton( val event: String, val text: String, val type: Int, val uri: String? = null ) @Serializable data class GotoIcon( @SerialName("icon_height") val iconHeight: Int, @SerialName("icon_night_url") val iconNightUrl: String, @SerialName("icon_url") val iconUrl: String, @SerialName("icon_width") val iconWidth: Int ) @Serializable data class PlayerArgs( val aid: Long, val cid: Long, val duration: Int, val type: String ) @Serializable data class RcmdReasonStyle( @SerialName("bg_color") val bgColor: String, @SerialName("bg_color_night") val bgColorNight: String, @SerialName("bg_style") val bgStyle: Int, @SerialName("border_color") val borderColor: String, @SerialName("border_color_night") val borderColorNight: String, val text: String, @SerialName("text_color") val textColor: String, @SerialName("text_color_night") val textColorNight: String ) @Serializable data class ThreePoint( @SerialName("dislike_reasons") val dislikeReasons: List, val feedbacks: List? = null, @SerialName("watch_later") val watchLater: Int? = null ) { @Serializable data class DislikeReason( val id: Int, val name: String, val toast: String ) @Serializable data class Feedback( val id: Int, val name: String, val toast: String ) } @Serializable data class ThreePointV2( val icon: String? = null, val reasons: List = emptyList(), val subtitle: String? = null, val title: String? = null, val type: String ) { @Serializable data class Reason( val id: Int, val name: String, val toast: String ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/home/RcmdTopData.kt ================================================ package dev.aaa1115910.biliapi.http.entity.home import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @Serializable data class RcmdTopData( @SerialName("business_card") val businessCard: JsonElement? = null, @SerialName("floor_info") val floorInfo: JsonElement? = null, val item: List, val mid: Long, @SerialName("preload_expose_pct") val preloadExposePct: Double, @SerialName("preload_floor_expose_pct") val preloadFloorExposePct: Double, @SerialName("side_bar_column") val sideBarColumn: List? = emptyList(), @SerialName("user_feature") val userFeature: JsonElement? = null ) { @Serializable data class RcmdItem( @SerialName("av_feature") val avFeature: JsonElement? = null, @SerialName("business_info") val businessInfo: BusinessInfo?, val bvid: String, val cid: Long, val duration: Int, @SerialName("enable_vt") val enableVt: Int, val goto: String, val id: Long, @SerialName("is_followed") val isFollowed: Int, @SerialName("is_stock") val isStock: Int, @SerialName("ogv_info") val ogvInfo: JsonElement? = null, val owner: Owner? = null, val pic: String, val pos: Int, val pubdate: Int, @SerialName("rcmd_reason") val rcmdReason: RcmdReason? = null, @SerialName("room_info") val roomInfo: JsonElement? = null, @SerialName("show_info") val showInfo: Int, val stat: Stat? = null, val title: String, @SerialName("track_id") val trackId: String, val uri: String ) { @Serializable data class BusinessInfo( @SerialName("activity_type") val activityType: Int, @SerialName("ad_cb") val adCb: String, @SerialName("ad_desc") val adDesc: String, @SerialName("adver_name") val adverName: String, val agency: String, val area: Int, @SerialName("asg_id") val asgId: Int, @SerialName("business_mark") val businessMark: BusinessMark, @SerialName("card_type") val cardType: Int, @SerialName("cm_mark") val cmMark: Int, @SerialName("contract_id") val contractId: String, @SerialName("creative_id") val creativeId: Int, @SerialName("creative_type") val creativeType: Int, val epid: Int, val id: Int, @SerialName("inline") val `inline`: Inline, val intro: String, @SerialName("is_ad") val isAd: Boolean, @SerialName("is_ad_loc") val isAdLoc: Boolean, val label: String, val litpic: String, val mid: String, val name: String, @SerialName("null_frame") val nullFrame: Boolean, val operater: String, val pic: String, @SerialName("pic_main_color") val picMainColor: String, @SerialName("pos_num") val posNum: Int, @SerialName("request_id") val requestId: String, @SerialName("res_id") val resId: Int, @SerialName("server_type") val serverType: Int, @SerialName("src_id") val srcId: Int, val stime: Int, val style: Int, @SerialName("sub_title") val subTitle: String, val title: String, val url: String ) { @Serializable data class BusinessMark( @SerialName("bg_border_color") val bgBorderColor: String, @SerialName("bg_color") val bgColor: String, @SerialName("bg_color_night") val bgColorNight: String, @SerialName("border_color") val borderColor: String, @SerialName("border_color_night") val borderColorNight: String, @SerialName("img_height") val imgHeight: Int, @SerialName("img_url") val imgUrl: String, @SerialName("img_width") val imgWidth: Int, val text: String, @SerialName("text_color") val textColor: String, @SerialName("text_color_night") val textColorNight: String, val type: Int ) @Serializable data class Inline( @SerialName("inline_barrage_switch") val inlineBarrageSwitch: Int, @SerialName("inline_type") val inlineType: Int, @SerialName("inline_url") val inlineUrl: String, @SerialName("inline_use_same") val inlineUseSame: Int ) } @Serializable data class Owner( val face: String, val mid: Long, val name: String ) @Serializable data class RcmdReason( val content: String? = null, @SerialName("reason_type") val reasonType: Int ) @Serializable data class Stat( val danmaku: Int, val like: Int, val view: Long, val vt: Int ) } @Serializable data class SideBarColumn( @SerialName("av_feature") val avFeature: JsonElement? = null, @SerialName("card_type") val cardType: String, @SerialName("card_type_en") val cardTypeEn: String, val comic: JsonElement? = null, val cover: String, val duration: Int, @SerialName("enable_vt") val enableVt: Int, val goto: String, @SerialName("horizontal_cover_16_10") val horizontalCover1610: String? = null, @SerialName("horizontal_cover_16_9") val horizontalCover169: String? = null, val id: Int, @SerialName("is_finish") val isFinish: Int, @SerialName("is_play") val isPlay: Int, @SerialName("is_rec") val isRec: Int, @SerialName("is_started") val isStarted: Int, @SerialName("new_ep") val newEp: NewEp, val pos: Int, val producer: List, @SerialName("room_info") val roomInfo: JsonElement? = null, val source: String, val stats: Stats, val styles: List = emptyList(), @SerialName("sub_title") val subTitle: String, val title: String, @SerialName("track_id") val trackId: String, val url: String ) { @Serializable data class NewEp( val cover: String, @SerialName("day_of_week") val dayOfWeek: Int, val duration: Int, val id: Int, @SerialName("index_show") val indexShow: String? = null, @SerialName("long_title") val longTitle: String? = null, @SerialName("pub_time") val pubTime: String?, val title: String ) @Serializable data class Producer( @SerialName("is_contribute") val isContribute: Int, val mid: Long, val name: String, val type: Int ) @Serializable data class Stats( val coin: Int, val danmaku: Int, val favorite: Int, val follow: Int, val likes: Int, val reply: Int, @SerialName("series_follow") val seriesFollow: Int? = null, @SerialName("series_view") val seriesView: Int? = null, val view: Long ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/index/IndexFilter.kt ================================================ package dev.aaa1115910.biliapi.http.entity.index val indexFilterSeasonVersion = mapOf( -1 to "全部", 1 to "正片", 2 to "电影", 3 to "其他", ) val indexFilterSpokenLanguageType = mapOf( -1 to "全部", 1 to "原声", 2 to "中文配音", ) val indexFilterArea = mapOf( -1 to "全部", 2 to "日本", 3 to "美国", 4 to "其他", ) val indexFilterIsFinish = mapOf( -1 to "全部", 1 to "完结", 0 to "连载", ) val indexFilterCopyright = mapOf( -1 to "全部", 3 to "独家", 1 to "其他", ) val indexFilterSeasonStatus = mapOf( -1 to "全部", 1 to "免费", 2 to "付费", 4 to "大会员", ) val indexFilterSeasonMonth = mapOf( -1 to "全部", 1 to "1月", 4 to "4月", 7 to "7月", 10 to "10月", ) val indexFilterYear = mapOf( "-1" to "全部", "[2026,2027)" to "2026", "[2025,2026)" to "2025", "[2024,2025)" to "2024", "[2023,2024)" to "2023", "[2022,2023)" to "2022", "[2021,2022)" to "2021", "[2020,2021)" to "2020", "[2019,2020)" to "2019", "[2018,2019)" to "2018", "[2017,2018)" to "2017", "[2016,2017)" to "2016", "[2015,2016)" to "2015", "[2010,2015)" to "2015-2010", "[2005,2010)" to "2009-2005", "[2000,2005)" to "2004-2000", "[1990,2000)" to "90年代", "[1980,1990)" to "80年代", "[,1980)" to "更早", ) val indexFilterStyleIdsAnime get() = IndexFilterStyle.animeStyles val indexFilterStyleIdsMovie get() = IndexFilterStyle.movieStyles val indexFilterStyleIdsDocumentary get() = IndexFilterStyle.documentaryStyles val indexFilterStyleIdsTV get() = IndexFilterStyle.tvStyles val indexFilterStyleIdsGuochuang get() = IndexFilterStyle.guochuangStyles val indexFilterStyleIdsVariety get() = IndexFilterStyle.varietyStyles val indexFilterAreaMovie get() = IndexFilterArea.movieAreas val indexFilterAreaTV get() = IndexFilterArea.tvAreas val indexFilterProducerId get() = IndexFilterProducerId.producerIds val indexFilterReleaseDate = mapOf( "-1" to "全部", "[2026-01-01 00:00:00,2027-01-01 00:00:00)" to "2026", "[2025-01-01 00:00:00,2026-01-01 00:00:00)" to "2025", "[2024-01-01 00:00:00,2025-01-01 00:00:00)" to "2024", "[2023-01-01 00:00:00,2024-01-01 00:00:00)" to "2023", "[2022-01-01 00:00:00,2023-01-01 00:00:00)" to "2022", "[2021-01-01 00:00:00,2022-01-01 00:00:00)" to "2021", "[2020-01-01 00:00:00,2021-01-01 00:00:00)" to "2020", "[2019-01-01 00:00:00,2020-01-01 00:00:00)" to "2019", "[2018-01-01 00:00:00,2019-01-01 00:00:00)" to "2018", "[2017-01-01 00:00:00,2018-01-01 00:00:00)" to "2017", "[2016-01-01 00:00:00,2017-01-01 00:00:00)" to "2016", "[2010-01-01 00:00:00,2016-01-01 00:00:00)" to "2015-2010", "[2005-01-01 00:00:00,2010-01-01 00:00:00)" to "2009-2005", "[2000-01-01 00:00:00,2005-01-01 00:00:00)" to "2004-2000", "[1990-01-01 00:00:00,2000-01-01 00:00:00)" to "90年代", "[1980-01-01 00:00:00,1990-01-01 00:00:00)" to "80年代", "[,1980-01-01 00:00:00)" to "更早", ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/index/IndexFilterArea.kt ================================================ package dev.aaa1115910.biliapi.http.entity.index object IndexFilterArea { private val areaFilter = mapOf( -1 to "全部", 1 to "中国大陆", 2 to "日本", 3 to "美国", 4 to "英国", 5 to "其他", 6 to "中国港台", 8 to "韩国", 9 to "法国", 10 to "泰国", 13 to "西班牙", 15 to "德国", 35 to "意大利", ) private val movieAreaIds = listOf( -1, 1, 6, 3, 28, 9, 4, 15, 10, 35, 13, 5 ) private val tvAreaIds = listOf( -1, 1, 2, 3, 4, 10, 5 ) val movieAreas by lazy { movieAreaIds.associateWith { areaFilter[it]!! } } val tvAreas by lazy { tvAreaIds.associateWith { areaFilter[it]!! } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/index/IndexFilterProducerId.kt ================================================ package dev.aaa1115910.biliapi.http.entity.index object IndexFilterProducerId { private val producerIdFilter = mapOf( -1 to "全部", 1 to "BBC", 2 to "NHK", 3 to "SKY", 4 to "央视", 5 to "ITV", 6 to "历史频道", 7 to "探索频道", 8 to "卫视", 9 to "自制", 10 to "ZDF", 11 to "合作机构", 12 to "国内其他", 13 to "国外其他", 14 to "国家地理", 15 to "索尼", 16 to "环球", 17 to "派拉蒙", 18 to "华纳", 19 to "迪士尼", 20 to "HBO", ) private val producerIdIds = listOf( -1, 4, 1, 7, 14, 2, 6, 8, 9, 5, 3, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20 ) val producerIds by lazy { producerIdIds.associateWith { producerIdFilter[it]!! } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/index/IndexFilterStyle.kt ================================================ package dev.aaa1115910.biliapi.http.entity.index object IndexFilterStyle { private val styleFilter = mapOf( -1 to "全部", -10 to "电影", 10010 to "原创", 10011 to "漫画改", 10012 to "小说改", 10013 to "游戏改", 10014 to "动态漫", 10015 to "布袋戏", 10016 to "热血", 10017 to "穿越", 10018 to "奇幻", 10019 to "玄幻", 10020 to "战斗", 10021 to "搞笑", 10022 to "日常", 10023 to "科幻", 10024 to "萌系", 10025 to "治愈", 10026 to "校园", 10027 to "少儿", 10028 to "泡面", 10029 to "恋爱", 10030 to "少女", 10031 to "魔法", 10032 to "冒险", 10033 to "历史", 10034 to "架空", 10035 to "机战", 10036 to "神魔", 10037 to "声控", 10038 to "运动", 10039 to "励志", 10040 to "音乐", 10041 to "推理", 10042 to "社团", 10043 to "智斗", 10044 to "催泪", 10045 to "美食", 10046 to "偶像", 10047 to "乙女", 10048 to "职场", 10049 to "古风", 10050 to "剧情", 10051 to "喜剧", 10052 to "爱情", 10053 to "动作", 10054 to "恐怖", 10055 to "犯罪", 10056 to "惊悚", 10057 to "悬疑", 10058 to "战争", //10059 10060 to "传记", 10061 to "家庭", 10062 to "歌剧", 10063 to "纪实", 10064 to "灾难", 10065 to "人文", 10066 to "科技", 10067 to "探险", 10068 to "宇宙", 10069 to "萌宠", 10070 to "社会", 10071 to "动物", 10072 to "自然", 10073 to "医疗", 10074 to "军事", 10075 to "罪案", 10076 to "神秘", 10077 to "旅行", 10078 to "武侠", 10079 to "青春", 10080 to "都市", 10081 to "古装", 10082 to "谍战", 10083 to "经典", 10084 to "情感", 10085 to "神话", 10086 to "年代", 10087 to "农村", 10088 to "刑侦", 10089 to "军旅", 10090 to "访谈", 10091 to "脱口秀", 10092 to "真人秀", //10093 10094 to "选秀", 10095 to "旅游", 10096 to "演唱会", 10097 to "亲子", 10098 to "晚会", 10099 to "养成", 10100 to "文化", //10101 10102 to "特摄", 10103 to "短剧", 10104 to "短片", ) private val animeStyleIds = listOf( -1, 10010, 10011, 10012, 10013, 10102, 10015, 10016, 10017, 10018, 10020, 10021, 10022, 10023, 10024, 10025, 10026, 10027, 10028, 10029, 10030, 10031, 10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048 ) private val guochuangStyleIds = listOf( -1, 10010, 10011, 10012, 10013, 10102, 10015, 10016, 10018, 10019, 10020, 10021, 10078, 10022, 10023, 10024, 10025, 10057, 10026, 10027, 10028, 10029, 10030, 10031, 10033, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048, 10049 ) private val varietyStyleIds = listOf( -1, 10040, 10090, 10091, 10092, 10094, 10045, 10095, 10098, 10096, 10084, 10051, 10097, 10100, 10048, 10069, 10099 ) private val movieStyleIds = listOf( -1, 10104, 10050, 10051, 10052, 10053, 10054, 10023, 10055, 10056, 10057 ) private val tvStyleIds = listOf( -1, 10021, 10018, 10058, 10078, 10079, 10103, 10080, 10081, 10082, 10083, 10084, 10057, 10039, 10085, 10017, 10086, 10087, 10088, 10050, 10061, 10033, 10089, 10023, ) private val documentaryStyleIds = listOf( -1, 10033, 10045, 10065, 10066, 10067, 10068, 10069, 10070, 10071, 10072, 10073, 10074, 10064, 10075, 10076, 10077, 10038, -10, ) val animeStyles by lazy { animeStyleIds.associateWith { styleFilter[it]!! } } val guochuangStyles by lazy { guochuangStyleIds.associateWith { styleFilter[it]!! } } val varietyStyles by lazy { varietyStyleIds.associateWith { styleFilter[it]!! } } val movieStyles by lazy { movieStyleIds.associateWith { styleFilter[it]!! } } val tvStyles by lazy { tvStyleIds.associateWith { styleFilter[it]!! } } val documentaryStyles by lazy { documentaryStyleIds.associateWith { styleFilter[it]!! } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/index/IndexOrder.kt ================================================ package dev.aaa1115910.biliapi.http.entity.index enum class IndexOrder(val id: Int) { UpdateTime(0), DanmakuCount(1), PlayCount(2), FollowCount(3), Score(4), StartTime(5), PublishTime(6) } private val animeIds = listOf(3, 0, 4, 2, 5) private val guochuangIds = listOf(3, 0, 4, 2, 5) private val varietyIds = listOf(2, 0, 6, 4, 1) private val tvIds = listOf(2, 0, 1, 3, 4) private val movieIds = listOf(2, 0, 6, 4) private val documentaryIds = listOf(2, 4, 0, 6, 1) val animeIndexOrders by lazy { animeIds.map { IndexOrder.entries[it] } } val guochuangIndexOrders by lazy { guochuangIds.map { IndexOrder.entries[it] } } val varietyIndexOrders by lazy { varietyIds.map { IndexOrder.entries[it] } } val tvIndexOrders by lazy { tvIds.map { IndexOrder.entries[it] } } val movieIndexOrders by lazy { movieIds.map { IndexOrder.entries[it] } } val documentaryIndexOrders by lazy { documentaryIds.map { IndexOrder.entries[it] } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/index/IndexResult.kt ================================================ package dev.aaa1115910.biliapi.http.entity.index import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class IndexResultData( @SerialName("has_next") val hasNext: Int, val list: List, val num: Int, val size: Int, val total: Int ) { @Serializable data class IndexResultItem( val badge: String, @SerialName("badge_info") val badgeInfo: BadgeInfo, @SerialName("badge_type") val badgeType: Int, val cover: String, @SerialName("first_ep") val firstEp: FirstEp, @SerialName("index_show") val indexShow: String, @SerialName("is_finish") val isFinish: Int, val link: String, @SerialName("media_id") val mediaId: Int, val order: String, @SerialName("order_type") val orderType: String, val score: String, @SerialName("season_id") val seasonId: Int, @SerialName("season_status") val seasonStatus: Int, @SerialName("season_type") val seasonType: Int, val subTitle: String, val title: String, @SerialName("title_icon") val titleIcon: String ) { @Serializable data class BadgeInfo( @SerialName("bg_color") val bgColor: String, @SerialName("bg_color_night") val bgColorNight: String, val text: String ) @Serializable data class FirstEp( val cover: String, @SerialName("ep_id") val epId: Int ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/live/HistoryDanmaku.kt ================================================ package dev.aaa1115910.biliapi.http.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive @Serializable data class HistoryDanmaku( //val admin:List, val room: List ) { @Serializable data class HistoryDanmakuItem( val text: String, @SerialName("dm_type") val dmType: Int, val uid: Long, val nickname: String, @SerialName("uname_color") val unameColor: String, val timeline: String, @SerialName("isadmin") val isAdmin: Int, val vip: Int, val svip: Int, @SerialName("medal") private val _medal: List, @Transient var medal: Medal? = null, val title: List, @SerialName("user_level") val userLevel: List, val rank: Int, @SerialName("teamid") val teamId: Int, val rnd: Int, @SerialName("user_title") val userTitle: String, @SerialName("guard_level") val guardLevel: Int, val bubble: Int, @SerialName("bubble_color") val bubbleColor: String, val lpl: Int, @SerialName("yeah_space_url") val yeahSpaceUrl: String, @SerialName("jump_to_url") val jumpToUrl: String, @SerialName("check_info") val checkInfo: CheckInfo, @SerialName("voice_dm_info") val voiceDmInfo: VoiceDmInfo, val emoticon: Emoticon ) { init { medal = runCatching { Medal( level = _medal[0].jsonPrimitive.int, name = _medal[1].jsonPrimitive.content, up = _medal[2].jsonPrimitive.content, roomId = _medal[3].jsonPrimitive.int ) }.getOrNull() } } } /** * 粉丝勋章 * * 返回样例 * ``` * [ * 16, //level * "迷你鲨", //name * "hufang360",//up * 22739471, //up room id * 12478086, * "", * 0, * 12478086, //medal_color_border(可能是) * 12478086, //medal_color_end(可能是) * 12478086, //medal_color_start(可能是) * 0, * 1, * 4328524 * ] * ``` * * @param level 勋章等级 * @param name 勋章名称 * @param up 主播昵称 * @param roomId 主播房间号 */ data class Medal( val level: Int, val name: String, val up: String, val roomId: Int ) /* "medal": [ 16, //level "迷你鲨", //name "hufang360",//up 22739471, //up room id 12478086, "", 0, 12478086, //medal_color_border 12478086, //medal_color_end 12478086, //medal_color_start 0, 1, 4328524 ], */ @Serializable data class CheckInfo( val ts: Int, val ct: String ) @Serializable data class VoiceDmInfo( @SerialName("voice_url") val voiceUrl: String, @SerialName("file_format") val fileFormat: String, val text: String, @SerialName("file_duration") val fileDuration: Int, @SerialName("file_id") val fileId: String ) @Serializable data class Emoticon( val id: Int, @SerialName("emoticon_unique") val emoticonUnique: String, val text: String, val perm: Int, val url: String, @SerialName("in_player_area") val inPlayerArea: Int, @SerialName("bulge_display") val bulgeDisplay: Int, @SerialName("is_dynamic") val isDynamic: Int, val height: Int, val width: Int ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/live/LiveDanmuInfoResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class DanmuInfoData( val group: String, @SerialName("business_id") val businessId: Int, @SerialName("refresh_row_factor") val refreshRowFactor: Float, @SerialName("refresh_rate") val refreshRate: Int, @SerialName("max_delay") val maxDelay: Int, val token: String, @SerialName("host_list") val hostList: List = emptyList() ) @Serializable data class HostListItem( val host: String, val port: Int, @SerialName("wss_port") val wssPort: Int, @SerialName("ws_port") val wsPort: Int ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/live/LiveEvent.kt ================================================ package dev.aaa1115910.biliapi.http.entity.live interface LiveEvent data class DanmakuEvent( val content: String, val mid: Long, val username: String, val medalName: String? = null, val medalLevel: Int? = null, val mode: Int = 1, // 弹幕模式:1=滚动,4=顶部,5=底部 val fontSize: Int = 25, // 字号大小 val color: Int = 0xFFFFFF, // 颜色(十进制RGB整数) val userLevel: Int = 0 // 用户等级 (0-60) ) : LiveEvent data class PopularityChangeEvent( val popularity: Int, val popularityText: String ) : LiveEvent data class OnlineRankCountEvent( val count: Int ) : LiveEvent ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/live/LiveFrame.kt ================================================ @file:Suppress("unused", "UNUSED_VARIABLE") package dev.aaa1115910.biliapi.http.entity.live import io.ktor.utils.io.core.ByteReadPacket import io.ktor.utils.io.core.buildPacket import io.ktor.utils.io.core.toByteArray import io.ktor.utils.io.core.writePacket import kotlinx.io.Source import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.DataInputStream internal fun Source.readFrameHeader(): FrameHeader = FrameHeader( readInt(), readShort(), readShort(), readInt(), readInt() ) /** * 数据包头部 * * @param totalLength 封包总大小(头部大小+正文大小) * @param headerLength 头部大小(一般为0x0010,16字节) * @param version 协议版本: 0普通包正文不使用压缩 1心跳及认证包正文不使用压缩2普通包正文使用zlib压缩 3普通包正文使用brotli压缩,解压为一个带头部的协议0普通包 * @param type 操作码(封包类型) * @param sequence 保留字段不使用 */ data class FrameHeader( val totalLength: Int, val headerLength: Short, val version: Short, val type: Int, val sequence: Int ) { val dataLength get() = totalLength - headerLength fun toBinary(): Source { return buildPacket { writeInt(this@FrameHeader.totalLength) writeShort(headerLength) writeShort(version) writeInt(this@FrameHeader.type) writeInt(sequence) } } } enum class FrameType(val code: Int) { HeartRequest(2), HeartResponse(3), Normal(5), AuthRequest(7), AuthResponse(8) } interface RequestFrame { fun toBinary(): Source } @Serializable data class AuthRequest( val uid: Int = 0, @SerialName("roomid") val roomId: Int, @SerialName("protover") val protoVer: Int = 3, val platform: String = "web", val type: Int = 2, val key: String = "" ) : RequestFrame { override fun toBinary(): Source { val data = Json.encodeToString(this).toByteArray() val header = FrameHeader( totalLength = data.size + 16, headerLength = 16, version = 1, type = FrameType.AuthRequest.code, sequence = 1 ) return buildPacket { this.writePacket(header.toBinary()) writePacket(ByteReadPacket(data)) } } } @Serializable data class AuthResponse( val code: Int = -1 ) { companion object { fun parse(data: ByteArray): AuthResponse { val bis = ByteArrayInputStream(data) val dis = DataInputStream(bis) val totalLength = dis.readInt() val headerLength = dis.readUnsignedShort() val version = dis.readUnsignedShort() val type = dis.readInt() val sequence = dis.readInt() //TODO do some verity val jsonString = String(dis.readBytes()) return Json.decodeFromString(jsonString) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/live/LiveRoomPlayInfoResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity.live import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RoomPlayInfoData( @SerialName("room_id") val roomId: Int, @SerialName("short_id") val shortId: Int, val uid: Long, @SerialName("need_p2p") val needP2P: Int, @SerialName("is_hidden") val isHidden: Boolean, @SerialName("is_locked") val isLocked: Boolean, @SerialName("is_portrait") val isPortrait: Boolean, @SerialName("live_status") val liveStatus: Int, @SerialName("hidden_till") val hiddenTill: Int, @SerialName("lock_till") val lockTill: Int, val encrypted: Boolean, @SerialName("pwd_verified") val pwdVerified: Boolean, @SerialName("live_time") val liveTime: Int, @SerialName("room_shield") val roomShield: Int, @SerialName("is_sp") val isSp: Int, @SerialName("special_type") val specialType: Int, val playUrl: String? = null, @SerialName("all_special_types") val allSpecialTypes: List = emptyList() ) /* "data": { "play_url": null, "all_special_types": [ 50 ] */ ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/login/Captcha.kt ================================================ package dev.aaa1115910.biliapi.http.entity.login import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * 申请 captcha 验证码结果 * * @param type 验证方式 用于判断使用哪一种验证方式,目前所见只有极验 geetest:极验 * @param token 登录 API token 与 captcha 无关,与登录接口有关 * @param geetest 极验 captcha 数据 * @param tencent 作用尚不明确 */ @Serializable data class CaptchaData( val type: String, val token: String, val geetest: Geetest, val tencent: Tencent ) { /** * 极验captcha数据 * * @param gt 极验id 一般为固定值 * @param challenge 极验KEY 由B站后端产生用于人机验证 */ @Serializable data class Geetest( val challenge: String, val gt: String ) @Serializable data class Tencent( @SerialName("appid") val appId: String ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/login/qr/AppQR.kt ================================================ package dev.aaa1115910.biliapi.http.entity.login.qr import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class AppQRDataRequest( val url: String, @SerialName("auth_code") val authCode: String ) @Serializable data class AppQRLoginData( @SerialName("is_new") val isNew: Boolean, val mid: Long, @SerialName("access_token") val accessToken: String, @SerialName("refresh_token") val refreshToken: String, @SerialName("expires_in") val expiresIn: Int, @SerialName("token_info") val tokenInfo: TokenInfo, @SerialName("cookie_info") val cookieInfo: CookieInfo, val sso: List = emptyList() ) { @Serializable data class TokenInfo( val mid: Long, @SerialName("expires_in") val expiresIn: Int, @SerialName("access_token") val accessToken: String, @SerialName("refresh_token") val refreshToken: String ) @Serializable data class CookieInfo( val cookies: List, val domains: List ) { @Serializable data class Cookie( var name: String, var value: String, @SerialName("http_only") var httpOnly: Int, val expires: Int, var secure: Int ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/login/qr/WebQR.kt ================================================ package dev.aaa1115910.biliapi.http.entity.login.qr import io.ktor.http.Cookie import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @Serializable data class RequestWebQRData( val url: String, @SerialName("qrcode_key") val qrcodeKey: String ) @Serializable data class QRLoginResponse( val code: Int, val message: String, val ttl: Int, val data: WebQRLoginData, @Transient var cookies: List = emptyList() ) @Serializable data class WebQRLoginData( val url: String, @SerialName("refresh_token") val refreshToken: String, val timestamp: Long, val code: Int, val message: String ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/login/sms/SendSmsResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity.login.sms import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * 发送验证码结果 */ @Serializable data class SendSmsResponse( @SerialName("captcha_key") val captchaKey: String, @SerialName("recaptcha_url") val recaptchaUrl: String ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/login/sms/SmsLoginResponse.kt ================================================ package dev.aaa1115910.biliapi.http.entity.login.sms import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class SmsLoginResponse( val status: Int, val message: String, val url: String, @SerialName("token_info") val tokenInfo: TokenInfo? = null, @SerialName("cookie_info") val cookieInfo: CookieInfo? = null, val sso: List = emptyList(), @SerialName("is_new") val isNew: Boolean, @SerialName("is_tourist") val isTourist: Boolean ) { @Serializable data class TokenInfo( val mid: Long, @SerialName("expires_in") val expiresIn: Int, @SerialName("access_token") val accessToken: String, @SerialName("refresh_token") val refreshToken: String ) @Serializable data class CookieInfo( val cookies: List, val domains: List ) { @Serializable data class Cookie( var name: String, var value: String, @SerialName("http_only") var httpOnly: Int, val expires: Int, var secure: Int, @SerialName("same_site") val sameSite: Int ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/pgc/PgcFeed.kt ================================================ package dev.aaa1115910.biliapi.http.entity.pgc import dev.aaa1115910.biliapi.http.entity.web.Hover import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray @Serializable data class PgcFeedData( @Suppress("SpellCheckingInspection") var coursor: Int, @SerialName("has_next") val hasNext: Boolean, var items: List = emptyList() ) { @Serializable data class FeedSubItem( val cover: String, @SerialName("episode_id") val episodeId: Int, val hover: Hover? = null, val link: String? = null, @SerialName("rank_id") val rankId: Int, val rating: String? = null, @SerialName("season_id") val seasonId: Int? = null, @SerialName("season_type") val seasonType: Int? = null, val stat: Stat? = null, @SerialName("sub_title") val subTitle: String, val text: JsonArray? = null, val title: String, val userStatus: UserStatus? = null ) { @Serializable data class Stat( val danmaku: Int, val duration: Int, val view: Long ) @Serializable data class UserStatus( val follow: Int ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/pgc/PgcFeedV3.kt ================================================ package dev.aaa1115910.biliapi.http.entity.pgc import dev.aaa1115910.biliapi.http.entity.web.Hover import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray @Serializable data class PgcFeedV3Data( @Suppress("SpellCheckingInspection") var coursor: Int, @SerialName("has_next") val hasNext: Boolean, val items: List ) { @Serializable data class FeedItem( @SerialName("rank_id") val rankId: Int, @SerialName("sub_items") val subItems: List, val text: JsonArray? = null ) { @Serializable data class FeedSubItem( @SerialName("card_style") val cardStyle: String, val cover: String, @SerialName("episode_id") val episodeId: Int? = null, val evaluate: String? = null, val hover: Hover? = null, val inline: Inline? = null, val link: String? = null, @SerialName("rank_id") val rankId: Int, val rating: String? = null, @SerialName("rating_count") val ratingCount: Int? = null, val report: Report, @SerialName("season_id") val seasonId: Int? = null, @SerialName("season_type") val seasonType: Int? = null, val stat: Stat? = null, @SerialName("sub_items") val subItems: List? = null, @SerialName("sub_title") val subTitle: String, val text: JsonArray? = null, val title: String, val userStatus: UserStatus? = null ) { @Serializable data class Inline( @SerialName("end_time") val endTime: Int? = null, @SerialName("ep_id") val epId: Int, @SerialName("first_ep") val firstEp: Int, @SerialName("material_no") val materialNo: String? = null, val scene: Int, @SerialName("start_time") val startTime: Int? = null ) @Serializable data class Report( @SerialName("first_ep") val firstEp: Int? = null, val scene: Int? = null ) @Serializable data class Stat( val danmaku: Int, val duration: Int, val view: Long ) @Serializable data class UserStatus( val follow: Int ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/pgc/PgcWebInitialStateData.kt ================================================ package dev.aaa1115910.biliapi.http.entity.pgc import dev.aaa1115910.biliapi.http.entity.web.Hover import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement /** * PGC 首页 ssr 数据 */ @Serializable data class PgcWebInitialStateData( val modules: Modules, ) { /** * @param banner 轮播图 * @param index 索引 * @param ext 时间表 */ @Suppress("KDocUnresolvedReference") @Serializable data class Modules( val banner: Banner, //val index: Index, //val ext:Ext, ) { @Serializable data class Banner( val title: String, val spmid: String, val size: Int, val style: String, val headers: JsonArray, val items: List, val wids: JsonArray, @SerialName("module_id") val moduleId: Int ) { @Serializable data class BannerItem( val rating: String? = null, val title: String, val cover: String, val link: String, val evaluate: String? = null, val report: JsonElement? = null, val hover: Hover? = null, val stat: Stat? = null, val values: JsonArray? = null, @SerialName("season_id") val seasonId: Int? = null, @SerialName("season_type") val seasonType: Int? = null, @SerialName("rating_count") val ratingCount: Int? = null, @SerialName("episode_id") val episodeId: Int? = null, @SerialName("big_cover") val bigCover: String? = null, @SerialName("play_btn") val playBtn: Int? = null, @SerialName("play_title") val playTitle: String? = null, @SerialName("rank_id") val rankId: Int, @SerialName("user_status") val userStatus: UserStatus? = null, @SerialName("date_ts") val dateTs: Int? = null, @SerialName("day_of_week") val dayOfWeek: Int? = null, @SerialName("is_today") val isToday: Int? = null, @SerialName("is_latest") val isLatest: Int? = null, val id: String, @SerialName("showReportData") val showReportData: ShowReportData, // 当前获取到的 json 中未包含 webpcover 和 webpbigcover //@SerialName("webpcover") //val webpCover: String, //@SerialName("webpbigcover") //val webpBigCover: String ) { @Serializable data class Stat( val view: Long ) @Serializable data class UserStatus( val follow: Int ) @Serializable data class ShowReportData( @SerialName("module_type") val moduleType: String, @SerialName("module_id") val moduleId: Int, @SerialName("ep_id") val epId: Int? = null, @SerialName("season_id") val seasonId: Int? = null ) } } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/proxy/PlayUrl.kt ================================================ package dev.aaa1115910.biliapi.http.entity.proxy import dev.aaa1115910.biliapi.http.entity.video.ClipInfo import dev.aaa1115910.biliapi.http.entity.video.DashData import dev.aaa1115910.biliapi.http.entity.video.DashFlac import dev.aaa1115910.biliapi.http.entity.video.Durl import dev.aaa1115910.biliapi.http.entity.video.RecordInfo import dev.aaa1115910.biliapi.http.entity.video.SegmentBase import dev.aaa1115910.biliapi.http.entity.video.SupportFormat import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ProxyWebPlayUrlData( val code: Int = 0, @SerialName("is_preview") val isPreview: Int = 0, val fnver: Int = 0, val fnval: Int = 0, @SerialName("video_project") val videoProject: Boolean = false, val type: String = "", val bp: Int = 0, @SerialName("vip_type") val vipType: Int = 0, @SerialName("vip_status") val vipStatus: Int = 0, @SerialName("is_drm") val isDrm: Boolean = false, @SerialName("no_rexcode") val noRexcode: Int = 0, @SerialName("has_paid") val hasPaid: Boolean = false, val status: Int = 0, val from: String, val result: String, val message: String, val quality: Int, val format: String, @SerialName("timelength") val timeLength: Int, @SerialName("accept_format") val acceptFormat: String, @SerialName("accept_description") val acceptDescription: List = emptyList(), @SerialName("accept_quality") val acceptQuality: List = emptyList(), @SerialName("video_codecid") val videoCodecId: Int, @SerialName("seek_param") val seekParam: String, @SerialName("seek_type") val seekType: String, val durl: List = emptyList(), val dash: ProxyWebDash? = null, @SerialName("support_formats") val supportFormats: List = emptyList(), @SerialName("last_play_time") val lastPlayTime: Int = 0, @SerialName("last_play_cid") val lastPlaycid: Long = 0, @SerialName("clip_info_list") val clipInfoList: List = emptyList(), @SerialName("record_info") val recordInfo: RecordInfo? = null ) @Serializable data class ProxyAppPlayUrlData( val code: Int = 0, @SerialName("is_preview") val isPreview: Int = 0, val fnver: Int = 0, val fnval: Int = 0, @SerialName("video_project") val videoProject: Boolean = false, val type: String = "", val bp: Int = 0, @SerialName("vip_type") val vipType: Int = 0, @SerialName("vip_status") val vipStatus: Int = 0, @SerialName("is_drm") val isDrm: Boolean = false, @SerialName("no_rexcode") val noRexcode: Int = 0, @SerialName("has_paid") val hasPaid: Boolean = false, val status: Int = 0, val from: String, val result: String, val message: String, val quality: Int, val format: String, @SerialName("timelength") val timeLength: Int, @SerialName("accept_format") val acceptFormat: String, @SerialName("accept_description") val acceptDescription: List = emptyList(), @SerialName("accept_quality") val acceptQuality: List = emptyList(), @SerialName("video_codecid") val videoCodecId: Int, @SerialName("seek_param") val seekParam: String, @SerialName("seek_type") val seekType: String, val durl: List = emptyList(), val dash: ProxyAppDash? = null, @SerialName("support_formats") val supportFormats: List = emptyList(), @SerialName("last_play_time") val lastPlayTime: Int = 0, @SerialName("last_play_cid") val lastPlaycid: Long = 0, @SerialName("clip_info_list") val clipInfoList: List = emptyList(), @SerialName("record_info") val recordInfo: RecordInfo? = null ) @Serializable data class ProxyWebDash( val duration: Int, val minBufferTime: Float, val video: List = emptyList(), val audio: List? = null, val dolby: ProxyWebDashDolby = ProxyWebDashDolby(), val flac: DashFlac? = null ) @Serializable data class ProxyWebDashDolby( val audio: List? = null, val type: Int = 2 ) @Serializable data class ProxyAppDash( val duration: Int, @SerialName("min_buffer_time") val minBufferTime: Float, val video: List = emptyList(), val audio: List? = null, val dolby: ProxyAppDashDolby = ProxyAppDashDolby(), val flac: DashFlac? = null ) @Serializable data class ProxyAppDashDolby( val audio: List? = null, val type: String = "" ) @Serializable data class ProxyAppDashData( val id: Int, @SerialName("base_url") val baseUrl: String, @SerialName("backup_url") val backupUrl: List = emptyList(), val bandwidth: Int, @SerialName("mime_type") val mimeType: String, val codecs: String, val width: Int, val height: Int, @SerialName("frame_rate") val frameRate: String, val sar: String, @SerialName("start_with_sap") val startWithSap: Int, @SerialName("segment_base") val segmentBase: SegmentBase, @SerialName("codecid") val codecId: Int ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/region/RegionBanner.kt ================================================ package dev.aaa1115910.biliapi.http.entity.region import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RegionBanner( @SerialName("region_banner_list") val regionBannerList: List ) @Serializable data class UgcRegionBannerItem( val image: String, val title: String, @SerialName("sub_title") val subTitle: String, val url: String, val rid: Int ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/region/RegionDynamic.kt ================================================ package dev.aaa1115910.biliapi.http.entity.region import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * 分区动态 * @param banner 轮播图 * @param card 卡片推荐位 * @param cBottom 往下滚动页面加载数据使用的参数 * @param cTop 往上滚动页面加载数据使用的参数 * @param new 推荐内容,可看作加载分区视频列表的第一页 */ @Serializable data class RegionDynamic( val banner: Banner? = null, val card: List = emptyList(), @SerialName("cbottom") val cBottom: Long, @SerialName("ctop") val cTop: Long, val new: List ) { @Serializable data class Banner( val top: List ) { @Serializable data class Top( @SerialName("client_ip") val clientIp: String? = null, @SerialName("cm_mark") val cmMark: Int, val hash: String, val id: Int, val image: String, val index: Int, @SerialName("is_ad") val isAd: Boolean? = null, @SerialName("is_ad_loc") val isAdLoc: Boolean? = null, @SerialName("request_id") val requestId: String, @SerialName("resource_id") val resourceId: Int, @SerialName("server_type") val serverType: Int, @SerialName("src_id") val srcId: Int? = null, val title: String, val uri: String ) } @Serializable data class Card( val body: List, @SerialName("card_id") val cardId: Int, val title: String, val type: String ) @Serializable data class Item( val cover: String, @SerialName("cover_left_icon_1") val coverLeftIcon1: Int? = null, @SerialName("cover_left_text_1") val coverLeftText1: String? = null, val danmaku: Int? = null, val duration: Int? = null, val face: String? = null, val favourite: Int? = null, val goto: String, val like: Int? = null, val name: String? = null, val param: String, val play: Long? = null, @SerialName("pubdate") val pubDate: Int, val reply: Int? = null, val rid: Int? = null, @SerialName("rname") val rName: String? = null, val title: String, val uri: String, val children: List? = null ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/region/RegionDynamicList.kt ================================================ package dev.aaa1115910.biliapi.http.entity.region import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RegionDynamicList( @SerialName("cbottom") val cBottom: Long, @SerialName("ctop") val cTop: Long, val new: List ) { @Serializable data class Item( val cover: String, @SerialName("cover_left_icon_1") val coverLeftIcon1: Int, @SerialName("cover_left_text_1") val coverLeftText1: String, val danmaku: Int? = null, val duration: Int, val face: String, val favourite: Int? = null, val goto: String, val like: Int? = null, val name: String, val param: String, val play: Long? = null, @SerialName("pubdate") val pubDate: Int, val reply: Int? = null, val rid: Int, @SerialName("rname") val rName: String, val title: String, val uri: String ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/region/RegionFeedRcmd.kt ================================================ package dev.aaa1115910.biliapi.http.entity.region import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class RegionFeedRcmd( val archives: List ) { @Serializable data class Archive( val aid: Long, val bvid: String, val cid: Long, val title: String, val cover: String, val duration: Int, val pubdate: Long, val stat: Stat, val author: Author, val trackid: String, val goto: String, @SerialName("rec_reason") val recReason: String ) { @Serializable data class Stat( val view: Long, val like: Int, val danmaku: Int ) @Serializable data class Author( val mid: Long, val name: String ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/region/RegionLocs.kt ================================================ package dev.aaa1115910.biliapi.http.entity.region import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @Serializable data class RegionLocs( @SerialName("ads_control") val adsControl: AdsControl, val code: Int, val count: Int, val data: Map>, val live: JsonObject? = null, val message: String ) { @Serializable data class AdsControl( @SerialName("has_danmu") val hasDanmu: Int, @SerialName("has_live_booking_ad") val hasLiveBookingAd: Boolean, @SerialName("under_player_scroller_seconds") val underPlayerScrollerSeconds: Int ) @Serializable data class LocData( @SerialName("activity_type") val activityType: Int, @SerialName("ad_cb") val adCb: String, @SerialName("ad_desc") val adDesc: String, @SerialName("adver_name") val adverName: String, val agency: String, val area: Int, @SerialName("asg_id") val asgId: Int, @SerialName("business_mark") val businessMark: JsonObject? = null, @SerialName("card_type") val cardType: Int, @SerialName("click_urls") val clickUrls: JsonObject? = null, @SerialName("cm_mark") val cmMark: Int, @SerialName("contract_id") val contractId: String, @SerialName("creative_type") val creativeType: Int, @SerialName("epid") val epId: Int, @SerialName("feedback_panel") val feedbackPanel: JsonObject? = null, val id: Int, val inline: Inline, val intro: String, @SerialName("is_ad_loc") val isAdLoc: Boolean, @SerialName("jump_target") val jumpTarget: Int, val label: String, @SerialName("litpic") val litPic: String, val mid: String, val name: String, @SerialName("null_frame") val nullFrame: Boolean, @SerialName("operater") val operater: String, val pic: String, @SerialName("pic_main_color") val picMainColor: String, @SerialName("pos_num") val posNum: Int, @SerialName("request_id") val requestId: String, @SerialName("res_id") val resId: Int, val room: JsonObject? = null, @SerialName("sales_type") val salesType: Int, val season: JsonObject? = null, @SerialName("server_type") val serverType: Int, @SerialName("show_urls") val showUrls: JsonObject? = null, @SerialName("src_id") val srcId: Int, @SerialName("stime") val sTime: Int, val style: Int, @SerialName("sub_title") val subTitle: String, val title: String, @SerialName("track_id") val trackId: String, val url: String ) { @Serializable data class Inline( @SerialName("inline_barrage_switch") val inlineBarrageSwitch: Int, @SerialName("inline_type") val inlineType: Int, @SerialName("inline_url") val inlineUrl: String, @SerialName("inline_use_same") val inlineUseSame: Int ) } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/reply/Comment.kt ================================================ package dev.aaa1115910.biliapi.http.entity.reply import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @Serializable data class CommentData( val assist: Int, val blacklist: Int, val callbacks: JsonElement? = null, @SerialName("cm_info") val cmInfo: CmInfo? = null, val config: Config, val control: Control, val cursor: Cursor, val effects: Effects, @SerialName("esports_grade_card") val esportsGradeCard: JsonElement? = null, val note: Int, val replies: List = emptyList(), val top: Top, @SerialName("top_replies") val topReplies: JsonElement? = null, @SerialName("up_selection") val upSelection: UpSelection? = null, val upper: Upper, val vote: Int ) { /** * 广告控制 */ @Serializable data class CmInfo( val ads: JsonElement ) /** * 游标信息 */ @Serializable data class Cursor( @SerialName("all_count") val allCount: Int? = null, @SerialName("is_begin") val isBegin: Boolean, @SerialName("is_end") val isEnd: Boolean, val mode: Int, @SerialName("mode_text") val modeText: String, val name: String, val next: Int, @SerialName("pagination_reply") val paginationReply: PaginationReply? = null, val prev: Int, @SerialName("session_id") val sessionId: String, @SerialName("support_mode") val supportMode: List = emptyList() ) { @Serializable data class PaginationReply( @SerialName("next_offset") val nextOffset: String? = null ) } @Serializable data class Effects( val preloading: String ) @Serializable data class Reply( val action: Int, val assist: Int, val attr: Long, val content: Content, val count: Int, val ctime: Int, val dialog: Long, @SerialName("dynamic_id_str") val dynamicIdStr: String, val fansgrade: Int, val folder: Folder, val invisible: Boolean, val like: Int, val member: Member, val mid: Long, val oid: Long, val parent: Long, @SerialName("parent_str") val parentStr: String, val rcount: Int, val replies: List = emptyList(), @SerialName("reply_control") val replyControl: ReplyControl, val root: Long, @SerialName("root_str") val rootStr: String, val rpid: Long, @SerialName("rpid_str") val rpidStr: String, val state: Int, val type: Long, @SerialName("up_action") val upAction: UpAction ) { /** * 评论内容 */ @Serializable data class Content( @SerialName("at_name_to_mid") val atNameToMid: Map = emptyMap(), val emote: Map = emptyMap(), @SerialName("jump_url") val jumpUrl: Map = emptyMap(), @SerialName("max_line") val maxLine: Int, val members: List = emptyList(), val message: String, val pictures: List = emptyList(), @SerialName("picture_scale") val pictureScale: Float = 1f ) { /** * 评论中的表情 */ @Serializable data class Emote( val attr: Int, val id: Int, @SerialName("jump_title") val jumpTitle: String, val meta: Meta, val mtime: Int, @SerialName("package_id") val packageId: Int, val state: Int, val text: String, val type: Int, val url: String ) { @Serializable data class Meta( val size: Int ) } /** * 超链接跳转,例如关键词之类的 */ @Serializable data class JumpUrl( @SerialName("app_name") val appName: String, @SerialName("app_package_name") val appPackageName: String, @SerialName("app_url_schema") val appUrlSchema: String, @SerialName("click_report") val clickReport: String, @SerialName("exposure_report") val exposureReport: String, val extra: Extra? = null, @SerialName("icon_position") val iconPosition: Int, @SerialName("is_half_screen") val isHalfScreen: Boolean, @SerialName("match_once") val matchOnce: Boolean, @SerialName("pc_url") val pcUrl: String, @SerialName("prefix_icon") val prefixIcon: String, val state: Int, val title: String, val underline: Boolean ) { @Serializable data class Extra( @SerialName("goods_click_report") val goodsClickReport: String, @SerialName("goods_cm_control") val goodsCmControl: Int, @SerialName("goods_exposure_report") val goodsExposureReport: String, @SerialName("goods_show_type") val goodsShowType: Int, @SerialName("is_word_search") val isWordSearch: Boolean ) } @Serializable data class Picture( @SerialName("img_src") val imgSrc: String, @SerialName("img_width") val imgWidth: Int, @SerialName("img_height") val imgHeight: Int, @SerialName("img_size") val imgSize: Float, @SerialName("top_right_icon") val topRightIcon: String? = null, @SerialName("play_gif_thumbnail") val playGifThumbnail: Boolean? = null ) } } /** * 评论折叠信息 */ @Serializable data class Folder( @SerialName("has_folded") val hasFolded: Boolean, @SerialName("is_folded") val isFolded: Boolean, val rule: String ) @Serializable data class Member( val avatar: String, @SerialName("avatar_item") val avatarItem: AvatarItem? = null, @SerialName("contract_desc") val contractDesc: String? = null, @SerialName("face_nft_new") val faceNftNew: Int, @SerialName("fans_detail") val fansDetail: JsonElement? = null, @SerialName("is_contractor") val isContractor: Boolean? = null, @SerialName("is_senior_member") val isSeniorMember: Int, @SerialName("level_info") val levelInfo: LevelInfo, val mid: String, val nameplate: Nameplate, @SerialName("nft_interaction") val nftInteraction: NftInteraction? = null, @SerialName("official_verify") val officialVerify: OfficialVerify, val pendant: Pendant, val rank: String, val senior: Senior, val sex: String, val sign: String, val uname: String, @SerialName("user_sailing") val userSailing: UserSailing? = null, val vip: Vip ) { @Serializable data class AvatarItem( @SerialName("container_size") val containerSize: ContainerSize, @SerialName("fallback_layers") val fallbackLayers: FallbackLayers, val mid: String ) { @Serializable data class ContainerSize( val height: Double, val width: Double ) } @Serializable data class LevelInfo( @SerialName("current_exp") val currentExp: Int, @SerialName("current_level") val currentLevel: Int, @SerialName("current_min") val currentMin: Int, @SerialName("next_exp") val nextExp: Int ) @Serializable data class Nameplate( val condition: String, val image: String, @SerialName("image_small") val imageSmall: String, val level: String, val name: String, val nid: Int ) @Serializable data class NftInteraction( val region: Region ) { @Serializable data class Region( val icon: String, @SerialName("show_status") val showStatus: Int, val type: Int ) } @Serializable data class OfficialVerify( val desc: String, val type: Int ) @Serializable data class Pendant( val expire: Int, val image: String, @SerialName("image_enhance") val imageEnhance: String, @SerialName("image_enhance_frame") val imageEnhanceFrame: String, val name: String, val pid: Int ) @Serializable data class Senior( val status: Int? = null ) @Serializable data class UserSailing( val cardbg: Cardbg? = null, @SerialName("cardbg_with_focus") val cardbgWithFocus: JsonElement? = null, val pendant: Pendant? = null ) { @Serializable data class Cardbg( val fan: Fan, val id: Long, val image: String, @SerialName("jump_url") val jumpUrl: String, val name: String, val type: String ) { @Serializable data class Fan( val color: String, @SerialName("is_fan") val isFan: Int, val name: String, @SerialName("num_desc") val numDesc: String, val number: Int ) } @Serializable data class Pendant( val id: Long, val image: String, @SerialName("image_enhance") val imageEnhance: String, @SerialName("image_enhance_frame") val imageEnhanceFrame: String, @SerialName("jump_url") val jumpUrl: String, val name: String, val type: String ) } @Serializable data class Vip( val accessStatus: Int, @SerialName("avatar_subscript") val avatarSubscript: Int, val dueRemark: String, val label: Label, @SerialName("nickname_color") val nicknameColor: String, val themeType: Int, val vipDueDate: Long, val vipStatus: Int, val vipStatusWarn: String, val vipType: Int ) { @Serializable data class Label( @SerialName("bg_color") val bgColor: String, @SerialName("bg_style") val bgStyle: Int, @SerialName("border_color") val borderColor: String, @SerialName("img_label_uri_hans") val imgLabelUriHans: String, @SerialName("img_label_uri_hans_static") val imgLabelUriHansStatic: String, @SerialName("img_label_uri_hant") val imgLabelUriHant: String, @SerialName("img_label_uri_hant_static") val imgLabelUriHantStatic: String, @SerialName("label_theme") val labelTheme: String, val path: String, val text: String, @SerialName("text_color") val textColor: String, @SerialName("use_img_label") val useImgLabel: Boolean ) } } @Serializable data class ReplyControl( val location: String? = null, @SerialName("max_line") val maxLine: Int, @SerialName("sub_reply_entry_text") val subReplyEntryText: String? = null, @SerialName("sub_reply_title_text") val subReplyTitleText: String? = null, @SerialName("time_desc") val timeDesc: String ) @Serializable data class UpAction( val like: Boolean, val reply: Boolean ) /** * 置顶 */ @Serializable data class Top( val admin: JsonElement? = null, val upper: JsonElement? = null, val vote: JsonElement? = null ) @Serializable data class UpSelection( @SerialName("ignore_count") val ignoreCount: Int, @SerialName("pending_count") val pendingCount: Int ) } /** * 评论区显示控制 */ @Serializable data class Config( @SerialName("read_only") val readOnly: Boolean, @SerialName("show_up_flag") val showUpFlag: Boolean, @SerialName("showtopic") val showtopic: Int ) /** * 评论区输入属性 */ @Serializable data class Control( @SerialName("answer_guide_android_url") val answerGuideAndroidUrl: String, @SerialName("answer_guide_icon_url") val answerGuideIconUrl: String, @SerialName("answer_guide_ios_url") val answerGuideIosUrl: String, @SerialName("answer_guide_text") val answerGuideText: String, @SerialName("bg_text") val bgText: String, @SerialName("child_input_text") val childInputText: String, @SerialName("disable_jump_emote") val disableJumpEmote: Boolean, @SerialName("empty_page") val emptyPage: JsonElement? = null, @SerialName("enable_charged") val enableCharged: Boolean, @SerialName("enable_cm_biz_helper") val enableCmBizHelper: Boolean, @SerialName("giveup_input_text") val giveupInputText: String, @SerialName("input_disable") val inputDisable: Boolean, @SerialName("root_input_text") val rootInputText: String, @SerialName("screenshot_icon_state") val screenshotIconState: Int, @SerialName("show_text") val showText: String, @SerialName("show_type") val showType: Int, @SerialName("upload_picture_icon_state") val uploadPictureIconState: Int, @SerialName("web_selection") val webSelection: Boolean ) @Serializable data class Upper( val mid: Long ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/reply/CommentReplyData.kt ================================================ package dev.aaa1115910.biliapi.http.entity.reply import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class CommentReplyData( val config: Config, val control: Control, val page: Page, val replies: List = emptyList(), val root: CommentData.Reply, // @SerialName("show_text") // val showText: String, // @SerialName("show_type") // val showType: Int, val upper: Upper ) { @Serializable data class Page( val count: Int, val num: Int, val size: Int ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/reply/Layers.kt ================================================ package dev.aaa1115910.biliapi.http.entity.reply import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @Serializable data class FallbackLayers( @SerialName("is_critical_group") val isCriticalGroup: Boolean, @SerialName("layers") val layers: List ) { @Serializable data class Layer( @SerialName("general_spec") val generalSpec: GeneralSpec, @SerialName("layer_config") val layerConfig: LayerConfig, val resource: Resource, val visible: Boolean ) { /** * @param tags AVATAR_LAYER ICON_LAYER PENDENT_LAYER */ @Serializable data class LayerConfig( @SerialName("is_critical") val isCritical: Boolean? = null, @SerialName("layer_mask") val layerMask: LayerMask? = null, val tags: Map = emptyMap() ) { @Serializable data class LayerMask( @SerialName("general_spec") val generalSpec: GeneralSpec, @SerialName("mask_src") val maskSrc: DrawSrc ) } @Serializable data class Resource( @SerialName("res_animation") val resAnimation: ResAnimation? = null, @SerialName("res_image") val resImage: ResImage? = null, @SerialName("res_native_draw") val resNativeDraw: ResNativeDraw? = null, @SerialName("res_type") val resType: Int ) { @Serializable data class ResAnimation( @SerialName("webp_src") val webpSrc: ImageSrc ) @Serializable data class ResImage( @SerialName("image_src") val imageSrc: ImageSrc ) @Serializable data class ResNativeDraw( @SerialName("draw_src") val drawSrc: DrawSrc ) } } } @Serializable data class GeneralSpec( @SerialName("pos_spec") val posSpec: PosSpec, @SerialName("render_spec") val renderSpec: RenderSpec, @SerialName("size_spec") val sizeSpec: SizeSpec ) { @Serializable data class PosSpec( @SerialName("axis_x") val axisX: Double, @SerialName("axis_y") val axisY: Double, @SerialName("coordinate_pos") val coordinatePos: Int ) @Serializable data class RenderSpec( val opacity: Int ) @Serializable data class SizeSpec( val height: Double, val width: Double ) } @Serializable data class ImageSrc( val local: Int? = null, @SerialName("placeholder") val placeholder: Int? = null, val remote: Remote? = null, @SerialName("src_type") val srcType: Int ) { @Serializable data class Remote( @SerialName("bfs_style") val bfsStyle: String, val url: String? = null ) } @Serializable data class DrawSrc( val draw: Draw, @SerialName("src_type") val srcType: Int ) { @Serializable data class Draw( @SerialName("color_config") val colorConfig: ColorConfig, @SerialName("draw_type") val drawType: Int, @SerialName("fill_mode") val fillMode: Int ) { @Serializable data class ColorConfig( val day: DayNight, @SerialName("is_dark_mode_aware") val isDarkModeAware: Boolean = false, val night: DayNight? = null ) { @Serializable data class DayNight( val argb: String ) } } } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/search/KeywordSuggest.kt ================================================ package dev.aaa1115910.biliapi.http.entity.search import dev.aaa1115910.biliapi.http.entity.search.KeywordSuggest.Result import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.json.JsonElement /** * @param result 当搜索词为空时为null,当有搜索建议时为[Result],当有搜索词但无搜索建议时为[List] */ @Serializable data class KeywordSuggest( @SerialName("exp_str") val expStr: String, val code: Int, //val cost: Cost, val msg: String? = null, val result: JsonElement? = null, @Transient val suggests: MutableList = mutableListOf(), //@SerialName("page caches") //val pageCaches: PageCaches, //val sengine: Sengine, val stoken: String ) { @Serializable data class Cost( val about: SearchCost ) @Serializable data class Result( val tag: List ) { /** * @param value 关键词内容 * @param term * @param ref 0 * @param name 显示内容 在无高亮显示时与value相同 有高亮显示时带有的xml标签 * @param spid */ @Serializable data class Tag( val value: String, val term: String, val ref: Int, val name: String, val spid: Int ) } @Serializable data class PageCaches( @SerialName("save cache") val saveCache: String ) @Serializable data class Sengine( val usage: Int ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/search/SearchCost.kt ================================================ package dev.aaa1115910.biliapi.http.entity.search import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class SearchCost( @SerialName("params_check") val paramsCheck: String, @SerialName("is_risk_query") val isRiskQuery: String? = null, @SerialName("illegal_handler") val illegalHandler: String? = null, @SerialName("as_response_format") val asResponseFormat: String? = null, @SerialName("mysql_request") val mysqlRequest: String? = null, @SerialName("as_request") val asRequest: String? = null, @SerialName("save_cache") val saveCache: String? = null, @SerialName("as_request_format") val asRequestFormat: String? = null, @SerialName("hotword_request") val hotwordRequest: String? = null, @SerialName("hotword_request_format") val hotwordRequestFormat: String? = null, @SerialName("hotword_response_format") val hotwordResponseFormat: String? = null, @SerialName("deserialize_response") val deserializeResponses: String? = null, val total: String, @SerialName("main_handler") val mainHandler: String? = null, ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/search/SearchResult.kt ================================================ package dev.aaa1115910.biliapi.http.entity.search import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** * 搜索结果 */ @Serializable data class SearchResultData( val seid: String, val page: Int, @SerialName("pagesize") val pageSize: Int, val numResults: Int, val numPages: Int, @SerialName("suggest_keyword") val suggestKeyword: String, @SerialName("rqt_type") val rqtType: String, @SerialName("cost_time") val costTime: SearchCost? = null, @SerialName("exp_list") val expList: JsonElement? = null, @SerialName("egg_hit") val eggHit: Int, @SerialName("pageinfo") val pageInfo: PageInfo? = null, @SerialName("top_tlist") val topTList: TopTList? = null, @SerialName("show_column") val showColumn: Int, @SerialName("show_module_list") val showModuleList: List? = null, @SerialName("app_display_option") val appDisplayOption: AppDisplayOption? = null, @SerialName("in_black_key") val inBlackKey: Int, @SerialName("in_white_key") val inWhiteKey: Int, val result: List? = mutableListOf(), @Transient val searchAllResults: MutableList> = mutableListOf(), @Transient val searchTypeResults: MutableList = mutableListOf(), @SerialName("is_search_page_grayed") val isSearchPageGrayed: Int? = null ) { init { result?.forEach { searchResultJsonElement -> val searchResultJsonObject = searchResultJsonElement.jsonObject var resultType = searchResultJsonObject["result_type"]?.jsonPrimitive?.content val json = Json { coerceInputValues = true ignoreUnknownKeys = true prettyPrint = true } if (resultType != null) { // 综合搜索 val searchResultDataJsonArray = searchResultJsonObject["data"]!!.jsonArray val data = when (resultType) { "activity" -> json.decodeFromJsonElement>( searchResultDataJsonArray ) "media_bangumi", "media_ft" -> json.decodeFromJsonElement>( searchResultDataJsonArray ) "video" -> json.decodeFromJsonElement>( searchResultDataJsonArray ) "live_room" -> json.decodeFromJsonElement>( searchResultDataJsonArray ) else -> { listOf() } } val resultResult = SearchResult( resultType = searchResultJsonObject["result_type"]!!.jsonPrimitive.content, data = data ) searchAllResults.add(resultResult) } else { // 分类搜索 resultType = searchResultJsonObject["type"]?.jsonPrimitive?.content val data = when (resultType) { "activity" -> json.decodeFromJsonElement( searchResultJsonObject ) "article" -> json.decodeFromJsonElement( searchResultJsonObject ) "bili_user" -> json.decodeFromJsonElement( searchResultJsonObject ) "live_room" -> json.decodeFromJsonElement( searchResultJsonObject ) "media_bangumi", "media_ft" -> json.decodeFromJsonElement( searchResultJsonObject ) "topic" -> json.decodeFromJsonElement(searchResultJsonObject) "video" -> json.decodeFromJsonElement(searchResultJsonObject) else -> { return@forEach } } searchTypeResults.add(data) } } } @Serializable data class PageInfo( @SerialName("live_room") val liveRoom: PageInfoData? = null, val pgc: PageInfoData? = null, @SerialName("operation_card") val operationCard: PageInfoData? = null, val tv: PageInfoData? = null, val movie: PageInfoData? = null, @SerialName("bili_user") val biliUser: PageInfoData? = null, @SerialName("live_master") val liveMaster: PageInfoData? = null, @SerialName("live_all") val liveAll: PageInfoData? = null, val topic: PageInfoData? = null, @SerialName("upuser") val upUser: PageInfoData? = null, val live: PageInfoData? = null, val video: PageInfoData? = null, val user: PageInfoData? = null, val bangumi: PageInfoData? = null, val activity: PageInfoData? = null, @SerialName("media_ft") val mediaFt: PageInfoData? = null, val article: PageInfoData? = null, @SerialName("media_bangumi") val mediaBangumi: PageInfoData? = null, val special: PageInfoData? = null, @SerialName("live_user") val liveUser: PageInfoData? = null ) { @Serializable data class PageInfoData( var numResults: Int, val total: Int, val pages: Int ) } @Serializable data class TopTList( @SerialName("live_room") val liveRoom: Int, val pgc: Int, @SerialName("operation_card") val operationCard: Int, val tv: Int, val movie: Int, @SerialName("bili_user") val biliUser: Int, @SerialName("live_master") val liveMaster: Int, @SerialName("topic") val topic: Int, @SerialName("upuser") val upUser: Int, val live: Int, val video: Int, val user: Int, val bangumi: Int, val activity: Int, @SerialName("media_ft") val mediaFt: Int, val article: Int, @SerialName("media_bangumi") val mediaBangumi: Int, val special: Int, val card: Int, @SerialName("live_user") val liveUser: Int, ) @Serializable data class AppDisplayOption( @SerialName("is_search_page_grayed") val isSearchPageGrayed: Int ) } @Serializable data class SearchResult( @SerialName("result_type") val resultType: String, val data: List ) ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/search/SearchResultItem.kt ================================================ package dev.aaa1115910.biliapi.http.entity.search import dev.aaa1115910.biliapi.http.entity.user.OfficialVerify import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.json.JsonElement @Serializable sealed class SearchResultItem /** * 活动(activity) */ @Serializable data class SearchActivityResult( val status: Int, val author: String, val url: String, val title: String, val cover: String, val pos: Int, @SerialName("card_type") val cardType: Int, val state: Int, val position: Int, val corner: String, @SerialName("card_value") val cardValue: String, val type: String, val id: Int, val desc: String ) : SearchResultItem() /** * 专栏(article) */ @Serializable data class SearchArticleResult( @SerialName("pub_time") val pubTime: Int, val like: Int, val title: String, @SerialName("rank_offset") val rankOffset: Int, val mid: Long, @SerialName("image_urls") val imageUrls: List, @SerialName("template_id") val templateId: Int, @SerialName("category_id") val categoryId: Int, @SerialName("sub_type") val subType: Int, val version: String, val view: Long, val reply: Int, @SerialName("rank_index") val rankIndex: Int, val desc: String, @SerialName("rank_score") val rankScore: Int? = null, val type: String, val id: Int, @SerialName("category_name") val categoryName: String ) : SearchResultItem() /** * 用户(bili_user) */ @Serializable data class SearchBiliUserResult( val type: String, val mid: Long, val uname: String, val usign: String, val fans: Int, val videos: Int, val upic: String, @SerialName("face_nft") val faceNft: Int, @SerialName("face_nft_type") val faceNftType: Int, @SerialName("verify_info") val verifyInfo: String, val level: Int, val gender: Int, @SerialName("is_upuser") val isUpUser: Int, @SerialName("is_live") val isLive: Int, @SerialName("room_id") val roomId: Int, val res: List, @SerialName("official_verify") val officialVerify: OfficialVerify, @SerialName("hit_columns") val hitColumns: List, @SerialName("is_senior_member") val isSeniorMember: Int ) : SearchResultItem() /** * 番剧(media_bangumi) 影视(mdeia_ft) * * @param type 结果类型 media_bangumi:番剧 media_ft:影视 * @param mediaId 剧集mdid * @param title 剧集标题 关键字用xml标签标注 * @param orgTitle 剧集原名 关键字用xml标签标注 可为空 * @param mediaType 剧集类型 1:番剧 2:电影 3:纪录片 4:国创 5:电视剧 7:综艺 * @param cv 声优 * @param staff 制作组 * @param seasonId 剧集ssid * @param isAvid * @param hitColumns 关键字匹配类型 * @param hitEpids 关键字匹配分集标题的分集epid 多个用,分隔 * @param seasonType 剧集类型 1:番剧 2:电影 3:纪录片 4:国创 5:电视剧 7:综艺 * @param seasonTypeName 剧集类型文字 * @param selectionStyle 分集选择按钮风格 horizontal:横排式 grid:按钮式 * @param epSize 结果匹配的分集数 * @param url 剧集重定向url * @param buttonText 观看按钮文字 * @param isFollow 是否追番 需要登录(SESSDATA) 未登录则恒为0 0:否 1:是 * @param isSelection * @param eps 结果匹配的分集信息 * @param badges 剧集标志信息 * @param cover 剧集封面url * @param areas 地区 * @param styles 风格 * @param gotoUrl 剧集重定向url * @param desc 简介 * @param pubTime 开播时间 时间戳 * @param mediaMode * @param fixPubTimeStr 开播时间重写信息 优先级高于[pubTime] 可为空 * @param mediaScore 评分信息 * @param displayInfo 剧集标志信息 * @param pgcSeasonId 剧集ssid * @param corner 角标有无 2:无 13:有 * @param indexShow */ @Serializable data class SearchMediaResult( val type: String, @SerialName("media_id") val mediaId: Int, val title: String, @SerialName("org_title") val orgTitle: String, @SerialName("media_type") val mediaType: Int, val cv: String, val staff: String, @SerialName("season_id") val seasonId: Int, @SerialName("is_avid") val isAvid: Boolean, @SerialName("hit_columns") val hitColumns: List? = null, @SerialName("hit_epids") val hitEpids: String, @SerialName("season_type") val seasonType: Int, @SerialName("season_type_name") val seasonTypeName: String, @SerialName("selection_style") val selectionStyle: String, @SerialName("ep_size") val epSize: Int, val url: String, @SerialName("button_text") val buttonText: String, @SerialName("is_follow") val isFollow: Int, @SerialName("is_selection") val isSelection: Int, val eps: List? = null, val badges: List? = null, val cover: String, val areas: String, val styles: String, @SerialName("goto_url") val gotoUrl: String, val desc: String, @SerialName("pubtime") val pubTime: Int, @SerialName("media_mode") val mediaMode: Int, @SerialName("fix_pubtime_str") val fixPubTimeStr: String, @SerialName("media_score") val mediaScore: MediaScore, @SerialName("display_info") val displayInfo: List? = null, @SerialName("pgc_season_id") val pgcSeasonId: Int, val corner: Int, @SerialName("index_show") val indexShow: String ) : SearchResultItem() { /** * 分集信息 * * @param id 分集epid * @param cover 分集封面url * @param title 完整标题 * @param url 分集重定向url * @param releaseDate * @param badges 分集标志 * @param indexTitle 短标题 * @param longTitle 单集标题 */ @Serializable data class SearchMediaEpisode( val id: Int, val cover: String, val title: String, val url: String, @SerialName("release_date") val releaseDate: String, val badges: List? = null, @SerialName("index_title") val indexTitle: String, @SerialName("long_title") val longTitle: String ) /** * 评分信息 * * @param score 评分 * @param userCount 总计评分人数 */ @Serializable data class MediaScore( val score: Float, @SerialName("user_count") val userCount: Int ) /** * @param text 剧集标志 * @param textColor 文字颜色 * @param textColorNight 夜间文字颜色 * @param bgColor 背景颜色 * @param bgColorNight 夜间背景颜色 * @param borderColor 背景颜色 * @param borderColorNight 夜间背景颜色 * @param bgStyle */ @Serializable data class Badge( val text: String, @SerialName("text_color") val textColor: String, @SerialName("text_color_night") val textColorNight: String, @SerialName("bg_color") val bgColor: String, @SerialName("bg_color_night") val bgColorNight: String, @SerialName("border_color") val borderColor: String, @SerialName("border_color_night") val borderColorNight: String, @SerialName("bg_style") val bgStyle: Int ) } /** * 话题(topic) * * @param type 结果类型 固定为topic * @param description 简介 * @param pubDate 发布时间 时间戳 * @param title 标题 * @param favourite * @param hitColumns 关键字匹配类型 * @param review * @param rankOffset 搜索结果排名值 * @param cover 话题封面url * @param update 上传时间 时间戳 * @param mid UP主mid * @param click * @param tpType * @param keyword * @param tpId 话题id * @param rankIndex * @param author UP主昵称 * @param arcUrl 话题页面重定向url * @param rankScore 结果排序量化值 */ @Serializable data class SearchTopicResult( val description: String, @SerialName("pubdate") val pubDate: Int, val title: String, val favourite: Int, @SerialName("hit_columns") val hitColumns: List, val review: Int, @SerialName("rank_offset") val rankOffset: Int, val cover: String, val update: Int, val mid: Long, val click: Int, @SerialName("tp_type") val tpType: Int, val keyword: String, @SerialName("tp_id") val tpId: Int, @SerialName("rank_index") val rankIndex: Int, val author: String, val type: String, @SerialName("arcurl") val arcUrl: String, @SerialName("rank_score") val rankScore: Int? = null ) : SearchResultItem() /** * 视频(video) * * @param type 结果类型 固定为video * @param id 稿件avid * @param author UP主昵称 * @param mid UP主mid * @param typeId 视频分区tid * @param typeName 视频子分区名 * @param arcUrl 视频重定向url * @param aid 稿件avid * @param bvid 稿件bvid * @param title 视频标题 关键字用xml标签标注 * @param description 视频简介 * @param arcRank * @param pic 视频封面url * @param play 视频播放量 * @param videoReview 视频弹幕量 * @param favorites 视频收藏数 * @param tag 视频TAG 每项TAG用,分隔 * @param review 视频评论数 * @param pubDate 视频投稿时间 时间戳 * @param sendDate 视频发布时间 时间戳 * @param duration 视频时长 HH:MM * @param badgePay * @param hitColumns 关键字匹配类型 * @param viewType * @param isPay * @param isUnionVideo 是否为合作视频 0:否 1:是 * @param recTags * @param newRecTags * @param rankScore 结果排序量化值 * @param like * @param upic * @param corner * @param cover * @param desc * @param url * @param recReason * @param danmaku * @param bizData * @param isChargeVideo */ @Serializable data class SearchVideoResult( val type: String, val id: Long, val author: String, val mid: Long, @SerialName("typeid") val typeId: String, @SerialName("typename") val typeName: String, @SerialName("arcurl") val arcUrl: String, val aid: Long, val bvid: String, val title: String, val description: String, @SerialName("arcrank") val arcRank: String? = null, val pic: String, val play: Long, @SerialName("video_review") val videoReview: Int, val favorites: Int, val tag: String, val review: Int, @SerialName("pubdate") val pubDate: Int, @SerialName("senddate") val sendDate: Int, val duration: String, @SerialName("badgepay") val badgePay: Boolean, @SerialName("hit_columns") val hitColumns: List, @SerialName("view_type") val viewType: String, @SerialName("is_pay") val isPay: Int, @SerialName("is_union_video") val isUnionVideo: Int, @SerialName("rec_tags") val recTags: JsonElement? = null, @SerialName("new_rec_tags") val newRecTags: List, @SerialName("rank_score") val rankScore: Int? = null, val like: Int, val upic: String, val corner: String, val cover: String, val desc: String, val url: String, @SerialName("rec_reason") val recReason: String, val danmaku: Int, @SerialName("biz_data") val bizData: JsonElement? = null, @SerialName("is_charge_video") val isChargeVideo: Int = 0, val vt: Int = 0, @SerialName("enable_vt") private val _enableVt: Int = 0, @Transient val enableVt: Boolean = _enableVt == 1, @SerialName("vt_display") val vtDisplay: String, val subtitle: String, @SerialName("episode_count_text") val episodeCountText: String, @SerialName("release_status") val releaseStatus: Int, @SerialName("is_intervene") val isIntervene: Int ) : SearchResultItem() /** * 直播间(live_room) */ @Serializable data class SearchLiveRoomResult( val type: String, val uid: Long, @SerialName("roomid") val roomId: Long, val title: String, val uname: String, @SerialName("uface") private val _uface: String, val online: Int, @SerialName("user_cover") val userCover: String, val cover: String, @SerialName("live_status") val liveStatus: Int, @SerialName("live_time") val liveTime: String, val tags: String, @SerialName("cate_name") val cateName: String, @SerialName("short_id") val shortId: Int? = 0, @SerialName("area_v2_name") val areaName: String? = null, @SerialName("area_v2_id") val areaId: Int? = 0, val attributions: JsonElement? = null, @SerialName("rank_index") val rankIndex: Int, @SerialName("rank_score") val rankScore: Int? = null, @SerialName("rank_offset") val rankOffset: Int, @SerialName("hit_columns") val hitColumns: List? = null, @SerialName("is_live") val isLive: Int? = null ) : SearchResultItem() { val uface: String get() = if (_uface.startsWith("//")) "https:$_uface" else _uface } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/search/SearchSquare.kt ================================================ package dev.aaa1115910.biliapi.http.entity.search import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @Serializable data class WebSearchSquareData( val trending: Trending ) { @Serializable data class Trending( val title: String, @SerialName("trackid") val trackId: String, val list: List, @SerialName("top_list") val topList: JsonElement? = null ) } /** * 热门关键词 * * @param keyword 关键词 * @param showName 完整关键词 * @param icon 图标 url * @param uri * @param goto */ @Serializable data class Hotword( val keyword: String, @SerialName("show_name") val showName: String, val icon: String, val uri: String, val goto: String ) @Serializable data class AppSearchSquareData( val type: String, val title: String, val data: SquareData? = null, @SerialName("search_ranking_meta") val searchRankingMeta: SearchRankingMeta? = null, @SerialName("history_hotword_display") val historyHotwordDisplay: Int ) { @Serializable data class SquareData( @SerialName("trackid") val trackId: String, val title: String? = null, val pages: Int? = null, @SerialName("exp_str") val expStr: String? = null, val list: List = emptyList(), @SerialName("hotword_egg_info") val hotwordEggInfo: Int? = null ) { @Serializable data class SquareDataItem( val keyword: String? = null, val status: String? = null, @SerialName("name_type") val nameType: String? = null, @SerialName("show_name") val showName: String? = null, @SerialName("word_type") val wordType: Int? = null, val icon: String? = null, val position: Int, @SerialName("module_id") val moduleId: Int? = null, @SerialName("resource_id") val resourceId: Int? = null, @SerialName("live_id") val liveId: List? = null, @SerialName("show_live_icon") val showLiveIcon: Boolean? = null, @SerialName("hot_id") val hotId: Int? = null, @SerialName("stat_datas") val statDatas: StatDatas? = null, val title: String? = null, val param: String? = null, val type: String? = null, val id: Long? = null, @SerialName("pub_time") val pubTime: String? = null, @SerialName("is_sug_style_exp") val isSugStyleExp: Int? = null, @SerialName("more_search_type") val moreSearchType: Int? = null, @SerialName("share_from") val shareFrom: String? = null ) { @Serializable data class StatDatas( @SerialName("is_commercial") val isCommercial: Int ) } } @Serializable data class SearchRankingMeta( @SerialName("open_search_ranking") val openSearchRanking: Boolean, val text: String, val link: String ) } @Serializable data class SearchTendingData( @SerialName("trackid") val trackId: String, val list: List, @SerialName("exp_str") val expStr: String? = null, @SerialName("hotword_egg_info") val hotwordEggInfo: Int ) { @Serializable data class Hotword( val position: Int, val keyword: String, @SerialName("show_name") val showName: String, @SerialName("word_type") val wordType: Int? = null, val icon: String? = null, @SerialName("hot_id") val hotId: Int, @SerialName("is_commercial") val isCommercial: Int ) } ================================================ FILE: bili-api/src/main/kotlin/dev/aaa1115910/biliapi/http/entity/season/AppSeasonData.kt ================================================ package dev.aaa1115910.biliapi.http.entity.season import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @Serializable data class AppSeasonData( @SerialName("activity_entrance") val activityEntrance: List = emptyList(), val actor: Actor, val alias: String, @SerialName("all_buttons") val allButtons: AllButtons, @SerialName("all_up_infos") val allUpInfos: Map = emptyMap(), val areas: List = emptyList(), val badge: String, val badgeInfo: Episode.BadgeInfo? = null, @SerialName("channel_entrance") val channelEntrance: List = emptyList(), val cover: String, val detail: String, @SerialName("dynamic_subtitle") val dynamicSubtitle: String, @SerialName("earphone_conf") val earphoneConf: EarphoneConf, @SerialName("enable_vt") val enableVt: Boolean, val evaluate: String, @SerialName("icon_font") val iconFont: IconFont, val link: String, @SerialName("media_badge_info") val mediaBadgeInfo: Episode.BadgeInfo, @SerialName("media_id") val mediaId: Int, val mode: Int, val modules: List = emptyList(), @SerialName("new_ep") val newEp: NewEP, @SerialName("new_keep_activity_material") val newKeepActivityMaterial: NewKeepActivityMaterial? = null, @SerialName("origin_name") val originName: String? = null, val payment: Payment, @SerialName("play_strategy") val playStrategy: PlayStrategy? = null, @SerialName("player_icon") val playerIcon: PlayerIcon? = null, val premieres: JsonArray? = null, @SerialName("producer_title") val producerTitle: String? = null, val publish: Publish, val rating: Rating? = null, val record: String, @SerialName("refine_cover") val refineCover: String, val reserve: Reserve, val right: SeasonRights? = null, @SerialName("season_id") val seasonId: Int, @SerialName("season_title") val seasonTitle: String, val series: Series, @SerialName("share_copy") val shareCopy: String, @SerialName("share_url") val shareUrl: String, @SerialName("short_link") val shortLink: String, @SerialName("show_season_type") val showSeasonType: Int, @SerialName("square_cover") val squareCover: String, val staff: Staff, val stat: Stat, val status: Int, val styles: List