From 938a42b67e8fe807484e795675239198eccc985c Mon Sep 17 00:00:00 2001 From: MUEDSA <7676275+muedsa@users.noreply.github.com> Date: Wed, 14 Aug 2024 18:43:50 +0800 Subject: [PATCH] update: refactor code & captcha validate --- app/build.gradle.kts | 4 +- .../compose/tv/widget/FullScreenWidgets.kt | 12 +- .../main/kotlin/com/muedsa/jcytv/AppModule.kt | 32 + .../kotlin/com/muedsa/jcytv/DatabaseModule.kt | 34 - .../com/muedsa/jcytv/DateStoreModule.kt | 19 - .../kotlin/com/muedsa/jcytv/MainActivity.kt | 12 +- app/src/main/kotlin/com/muedsa/jcytv/Perfs.kt | 5 +- .../com/muedsa/jcytv/PlaybackActivity.kt | 2 +- .../exception/NeedValidateCaptchaException.kt | 3 + .../com/muedsa/jcytv/model/AppSettingModel.kt | 3 + .../com/muedsa/jcytv/model/JcyHomeData.kt | 6 - .../com/muedsa/jcytv/model/JcyRankList.kt | 6 + .../com/muedsa/jcytv/model/JcyVideoRow.kt | 6 + .../muedsa/jcytv/repository/AppRepository.kt | 4 - .../muedsa/jcytv/repository/DataStoreRepo.kt | 2 +- .../com/muedsa/jcytv/repository/JcyRepo.kt | 89 + .../{ui/nav => screens}/AppNavigation.kt | 18 +- .../{ui/nav => screens}/NavigationItems.kt | 4 +- .../jcytv/screens/captcha/CaptchaScreen.kt | 204 ++ .../jcytv/screens/captcha/CaptchaViewModel.kt | 77 + .../detail/AnimeDanmakuSelectorWidget.kt | 3 +- .../detail/AnimeDetailScreen.kt | 14 +- .../detail}/AnimeDetailViewModel.kt | 20 +- .../detail/EpisodeListWidget.kt | 2 +- .../home/HomeNavScreen.kt | 8 +- .../features => screens}/home/HomeNavTab.kt | 2 +- .../home/HomeNavTabWidget.kt | 22 +- .../home/catalog/CatalogOptionsWidget.kt | 2 +- .../home/catalog/CatalogScreen.kt | 15 +- .../home/catalog}/CatalogViewModel.kt | 25 +- .../home/favorites}/FavoriteViewModel.kt | 4 +- .../home/favorites/FavoritesScreen.kt | 11 +- .../jcytv/screens/home/main/MainScreen.kt | 214 ++ .../screens/home/main/MainScreenViewModel.kt | 51 + .../home/rank/RankItemWidget.kt} | 4 +- .../jcytv/screens/home/rank/RankScreen.kt | 43 + .../screens/home/rank/RankScreenViewModel.kt | 51 + .../jcytv/screens/home/rank/RankWidget.kt | 70 + .../jcytv/screens/home/search/SearchInput.kt | 80 + .../jcytv/screens/home/search/SearchResult.kt | 72 + .../jcytv/screens/home/search/SearchScreen.kt | 50 + .../screens/home/search/SearchViewModel.kt | 49 + .../playback/PlaybackScreen.kt | 3 +- .../playback}/PlaybackViewModel.kt | 4 +- .../setting/AppSettingScreen.kt | 22 +- .../setting}/AppSettingViewModel.kt | 19 +- .../com/muedsa/jcytv/{ui => theme}/Colors.kt | 2 +- .../com/muedsa/jcytv/{ui => theme}/Size.kt | 2 +- .../jcytv/ui/features/home/main/MainScreen.kt | 198 -- .../jcytv/ui/features/home/rank/RankScreen.kt | 96 - .../ui/features/home/search/SearchScreen.kt | 164 -- .../kotlin/com/muedsa/jcytv/util/JcyConst.kt | 18 + .../muedsa/jcytv/util/JcyHtmlParserTool.kt | 109 + .../com/muedsa/jcytv/util/JcyHtmlTool.kt | 274 -- .../muedsa/jcytv/util/JcyPlaySourceTool.kt | 117 + .../muedsa/jcytv/util/JcyRotateCaptchaTool.kt | 67 + .../jcytv/viewmodel/HomePageViewModel.kt | 43 - .../muedsa/jcytv/viewmodel/RankViewModel.kt | 44 - .../muedsa/jcytv/viewmodel/SearchViewModel.kt | 45 - app/src/main/res/values/themes.xml | 1 + .../baselineProfiles/baseline-prof.txt | 2578 +++++++++-------- .../baselineProfiles/startup-prof.txt | 2578 +++++++++-------- .../jcytv/util/JcyHtmlParserToolTest.kt | 77 + .../com/muedsa/jcytv/util/JcyHtmlToolTest.kt | 130 - .../jcytv/util/JcyPlaySourceToolTest.kt | 48 + .../jcytv/util/JcyRotateCaptchaToolTest.kt | 12 + gradle/libs.versions.toml | 4 +- 67 files changed, 4387 insertions(+), 3622 deletions(-) delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/DatabaseModule.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/DateStoreModule.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/exception/NeedValidateCaptchaException.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/model/JcyHomeData.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/model/JcyRankList.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/model/JcyVideoRow.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/repository/AppRepository.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/repository/JcyRepo.kt rename app/src/main/kotlin/com/muedsa/jcytv/{ui/nav => screens}/AppNavigation.kt (84%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/nav => screens}/NavigationItems.kt (88%) create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaScreen.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaViewModel.kt rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/detail/AnimeDanmakuSelectorWidget.kt (98%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/detail/AnimeDetailScreen.kt (98%) rename app/src/main/kotlin/com/muedsa/jcytv/{viewmodel => screens/detail}/AnimeDetailViewModel.kt (92%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/detail/EpisodeListWidget.kt (99%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/home/HomeNavScreen.kt (85%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/home/HomeNavTab.kt (78%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/home/HomeNavTabWidget.kt (88%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/home/catalog/CatalogOptionsWidget.kt (97%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/home/catalog/CatalogScreen.kt (96%) rename app/src/main/kotlin/com/muedsa/jcytv/{viewmodel => screens/home/catalog}/CatalogViewModel.kt (91%) rename app/src/main/kotlin/com/muedsa/jcytv/{viewmodel => screens/home/favorites}/FavoriteViewModel.kt (87%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/home/favorites/FavoritesScreen.kt (94%) create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreen.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreenViewModel.kt rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features/home/rank/RankAnimeWidget.kt => screens/home/rank/RankItemWidget.kt} (97%) create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreen.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreenViewModel.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankWidget.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchInput.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchResult.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchScreen.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchViewModel.kt rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/playback/PlaybackScreen.kt (98%) rename app/src/main/kotlin/com/muedsa/jcytv/{viewmodel => screens/playback}/PlaybackViewModel.kt (97%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui/features => screens}/setting/AppSettingScreen.kt (94%) rename app/src/main/kotlin/com/muedsa/jcytv/{viewmodel => screens/setting}/AppSettingViewModel.kt (80%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui => theme}/Colors.kt (83%) rename app/src/main/kotlin/com/muedsa/jcytv/{ui => theme}/Size.kt (85%) delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/main/MainScreen.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/rank/RankScreen.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/search/SearchScreen.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/util/JcyConst.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlParserTool.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlTool.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/util/JcyPlaySourceTool.kt create mode 100644 app/src/main/kotlin/com/muedsa/jcytv/util/JcyRotateCaptchaTool.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/viewmodel/HomePageViewModel.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/viewmodel/RankViewModel.kt delete mode 100644 app/src/main/kotlin/com/muedsa/jcytv/viewmodel/SearchViewModel.kt create mode 100644 app/src/test/kotlin/com/muedsa/jcytv/util/JcyHtmlParserToolTest.kt delete mode 100644 app/src/test/kotlin/com/muedsa/jcytv/util/JcyHtmlToolTest.kt create mode 100644 app/src/test/kotlin/com/muedsa/jcytv/util/JcyPlaySourceToolTest.kt create mode 100644 app/src/test/kotlin/com/muedsa/jcytv/util/JcyRotateCaptchaToolTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 06a1bdc..0c42e1b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { applicationId = "com.muedsa.jcytv" minSdk = 24 targetSdk = 34 - versionCode = 12 - versionName = "1.0.0-rc01" + versionCode = 13 + versionName = "1.0.0-rc02" vectorDrawables { useSupportLibrary = true } diff --git a/app/src/main/kotlin/com/muedsa/compose/tv/widget/FullScreenWidgets.kt b/app/src/main/kotlin/com/muedsa/compose/tv/widget/FullScreenWidgets.kt index 9ddcf69..f27062b 100644 --- a/app/src/main/kotlin/com/muedsa/compose/tv/widget/FullScreenWidgets.kt +++ b/app/src/main/kotlin/com/muedsa/compose/tv/widget/FullScreenWidgets.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Refresh import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -41,7 +42,10 @@ fun EmptyDataScreen(model: Boolean = false) { @Composable -fun ErrorScreen(onRefresh: (() -> Unit)? = null) { +fun ErrorScreen( + onError: (() -> Unit)? = null, + onRefresh: (() -> Unit)? = null +) { Column( modifier = Modifier .fillMaxSize() @@ -57,6 +61,12 @@ fun ErrorScreen(onRefresh: (() -> Unit)? = null) { } } } + + if (onError != null) { + LaunchedEffect(key1 = Unit) { + onError.invoke() + } + } } diff --git a/app/src/main/kotlin/com/muedsa/jcytv/AppModule.kt b/app/src/main/kotlin/com/muedsa/jcytv/AppModule.kt index 052a315..b9554a5 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/AppModule.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/AppModule.kt @@ -1,11 +1,17 @@ package com.muedsa.jcytv +import android.content.Context +import androidx.room.Room import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.muedsa.jcytv.repository.DataStoreRepo +import com.muedsa.jcytv.repository.JcyRepo +import com.muedsa.jcytv.room.AppDatabase import com.muedsa.jcytv.service.DanDanPlayApiService import com.muedsa.uitl.LenientJson import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -17,6 +23,32 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) internal object AppModule { + @Singleton + @Provides + fun provideDataStoreRepository(@ApplicationContext app: Context) = DataStoreRepo(app) + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext appContext: Context): AppDatabase { + return Room.databaseBuilder( + appContext, + AppDatabase::class.java, + "JCYTV" + ).build() + } + + @Provides + @Singleton + fun provideFavoriteAnimeDao(appDatabase: AppDatabase) = appDatabase.favoriteAnimeDao() + + @Provides + @Singleton + fun provideEpisodeProgressDao(appDatabase: AppDatabase) = appDatabase.episodeProgressDao() + + @Provides + @Singleton + fun provideJcyRepository(dataStoreRepo: DataStoreRepo): JcyRepo = JcyRepo(dataStoreRepo) + @Provides @Singleton fun provideDanDanPlayApiService(): DanDanPlayApiService { diff --git a/app/src/main/kotlin/com/muedsa/jcytv/DatabaseModule.kt b/app/src/main/kotlin/com/muedsa/jcytv/DatabaseModule.kt deleted file mode 100644 index a51f22d..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/DatabaseModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.muedsa.jcytv - -import android.content.Context -import androidx.room.Room -import com.muedsa.jcytv.room.AppDatabase -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object DatabaseModule { - - @Provides - @Singleton - fun provideAppDatabase(@ApplicationContext appContext: Context): AppDatabase { - return Room.databaseBuilder( - appContext, - AppDatabase::class.java, - "JCYTV" - ).build() - } - - @Provides - @Singleton - fun provideFavoriteAnimeDao(appDatabase: AppDatabase) = appDatabase.favoriteAnimeDao() - - @Provides - @Singleton - fun provideEpisodeProgressDao(appDatabase: AppDatabase) = appDatabase.episodeProgressDao() -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/DateStoreModule.kt b/app/src/main/kotlin/com/muedsa/jcytv/DateStoreModule.kt deleted file mode 100644 index 28e86ba..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/DateStoreModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.muedsa.jcytv - -import android.content.Context -import com.muedsa.jcytv.repository.DataStoreRepo -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object DateStoreModule { - - @Singleton - @Provides - fun provideDataStoreRepository(@ApplicationContext app: Context) = DataStoreRepo(app) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/MainActivity.kt b/app/src/main/kotlin/com/muedsa/jcytv/MainActivity.kt index 7db2710..304e122 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/MainActivity.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/MainActivity.kt @@ -7,21 +7,21 @@ import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.muedsa.compose.tv.theme.TvTheme import com.muedsa.compose.tv.widget.Scaffold -import com.muedsa.jcytv.ui.nav.AppNavigation -import com.muedsa.jcytv.viewmodel.HomePageViewModel -import com.muedsa.model.LazyType +import com.muedsa.jcytv.screens.AppNavigation +import com.muedsa.jcytv.screens.home.main.MainScreenUiState +import com.muedsa.jcytv.screens.home.main.MainScreenViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val homePageViewModel: HomePageViewModel by viewModels() + private val mainScreenViewModel: MainScreenViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { - val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + val splashScreen = installSplashScreen() splashScreen.setKeepOnScreenCondition { - homePageViewModel.homeRowsSF.value.type == LazyType.LOADING + mainScreenViewModel.uiState.value is MainScreenUiState.Loading } setContent { TvTheme { diff --git a/app/src/main/kotlin/com/muedsa/jcytv/Perfs.kt b/app/src/main/kotlin/com/muedsa/jcytv/Perfs.kt index 8c88481..b54b1db 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/Perfs.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/Perfs.kt @@ -2,6 +2,7 @@ package com.muedsa.jcytv import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey val KEY_DANMAKU_ENABLE = booleanPreferencesKey("danmaku_enable") @@ -11,4 +12,6 @@ val KEY_DANMAKU_SIZE_SCALE = intPreferencesKey("danmaku_size_scale") val KEY_DANMAKU_ALPHA = intPreferencesKey("danmaku_alpha") -val KEY_DANMAKU_SCREEN_PART = intPreferencesKey("danmaku_size_part") \ No newline at end of file +val KEY_DANMAKU_SCREEN_PART = intPreferencesKey("danmaku_size_part") + +val KEY_CAPTCHA_GUARD_OK = stringPreferencesKey("captcha_guard_ok") \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/PlaybackActivity.kt b/app/src/main/kotlin/com/muedsa/jcytv/PlaybackActivity.kt index 476f1c9..e890b8e 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/PlaybackActivity.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/PlaybackActivity.kt @@ -13,7 +13,7 @@ import com.muedsa.compose.tv.useLocalErrorMsgBoxController import com.muedsa.compose.tv.widget.AppBackHandler import com.muedsa.compose.tv.widget.FillTextScreen import com.muedsa.compose.tv.widget.Scaffold -import com.muedsa.jcytv.ui.features.playback.PlaybackScreen +import com.muedsa.jcytv.screens.playback.PlaybackScreen import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/app/src/main/kotlin/com/muedsa/jcytv/exception/NeedValidateCaptchaException.kt b/app/src/main/kotlin/com/muedsa/jcytv/exception/NeedValidateCaptchaException.kt new file mode 100644 index 0000000..123c5d5 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/exception/NeedValidateCaptchaException.kt @@ -0,0 +1,3 @@ +package com.muedsa.jcytv.exception + +class NeedValidateCaptchaException : RuntimeException() \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/model/AppSettingModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/model/AppSettingModel.kt index 2cd79ee..dd2203e 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/model/AppSettingModel.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/model/AppSettingModel.kt @@ -1,6 +1,7 @@ package com.muedsa.jcytv.model import androidx.datastore.preferences.core.Preferences +import com.muedsa.jcytv.KEY_CAPTCHA_GUARD_OK import com.muedsa.jcytv.KEY_DANMAKU_ALPHA import com.muedsa.jcytv.KEY_DANMAKU_ENABLE import com.muedsa.jcytv.KEY_DANMAKU_MERGE_ENABLE @@ -13,6 +14,7 @@ data class AppSettingModel( val danmakuSizeScale: Int, val danmakuAlpha: Int, val danmakuScreenPart: Int, + val captchaGuardOk: String, ) { companion object { @@ -24,6 +26,7 @@ data class AppSettingModel( danmakuSizeScale = prefs[KEY_DANMAKU_SIZE_SCALE] ?: 140, danmakuAlpha = prefs[KEY_DANMAKU_ALPHA] ?: 100, danmakuScreenPart = prefs[KEY_DANMAKU_SCREEN_PART] ?: 100, + captchaGuardOk = prefs[KEY_CAPTCHA_GUARD_OK] ?: "", ) } diff --git a/app/src/main/kotlin/com/muedsa/jcytv/model/JcyHomeData.kt b/app/src/main/kotlin/com/muedsa/jcytv/model/JcyHomeData.kt deleted file mode 100644 index fcdade6..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/model/JcyHomeData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.muedsa.jcytv.model - -class JcyHomeData( - val hotList: List = emptyList(), - val newList: List = emptyList() -) \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/model/JcyRankList.kt b/app/src/main/kotlin/com/muedsa/jcytv/model/JcyRankList.kt new file mode 100644 index 0000000..5d79f4e --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/model/JcyRankList.kt @@ -0,0 +1,6 @@ +package com.muedsa.jcytv.model + +class JcyRankList( + val title: String, + val list: List +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/model/JcyVideoRow.kt b/app/src/main/kotlin/com/muedsa/jcytv/model/JcyVideoRow.kt new file mode 100644 index 0000000..a763c89 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/model/JcyVideoRow.kt @@ -0,0 +1,6 @@ +package com.muedsa.jcytv.model + +class JcyVideoRow( + val title: String, + val list: List +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/repository/AppRepository.kt b/app/src/main/kotlin/com/muedsa/jcytv/repository/AppRepository.kt deleted file mode 100644 index a212290..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/repository/AppRepository.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.muedsa.jcytv.repository - -class AppRepository { -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/repository/DataStoreRepo.kt b/app/src/main/kotlin/com/muedsa/jcytv/repository/DataStoreRepo.kt index 8d42894..1f2bf28 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/repository/DataStoreRepo.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/repository/DataStoreRepo.kt @@ -10,6 +10,6 @@ private const val PREFS_NAME = "setting" private val Context.dataStore: DataStore by preferencesDataStore(name = PREFS_NAME) -class DataStoreRepo @Inject constructor(private val context: Context) { +class DataStoreRepo @Inject constructor(context: Context) { val dataStore: DataStore = context.dataStore } \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/repository/JcyRepo.kt b/app/src/main/kotlin/com/muedsa/jcytv/repository/JcyRepo.kt new file mode 100644 index 0000000..a76eea9 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/repository/JcyRepo.kt @@ -0,0 +1,89 @@ +package com.muedsa.jcytv.repository + +import com.google.common.net.HttpHeaders +import com.muedsa.jcytv.KEY_CAPTCHA_GUARD_OK +import com.muedsa.jcytv.exception.NeedValidateCaptchaException +import com.muedsa.jcytv.model.JcyRankList +import com.muedsa.jcytv.model.JcySimpleVideoInfo +import com.muedsa.jcytv.model.JcyVideoDetail +import com.muedsa.jcytv.model.JcyVideoRow +import com.muedsa.jcytv.util.JcyConst +import com.muedsa.jcytv.util.JcyHtmlParserTool +import com.muedsa.jcytv.util.JcyPlaySourceTool.CHROME_USER_AGENT +import com.muedsa.jcytv.util.JcyRotateCaptchaTool +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import javax.inject.Inject + +class JcyRepo @Inject constructor(dataStoreRepo: DataStoreRepo) { + + private val guardOkFlow: Flow = dataStoreRepo.dataStore.data + .map { it[KEY_CAPTCHA_GUARD_OK] ?: "" } + + private suspend fun getGuardOk(): String = guardOkFlow.firstOrNull() ?: "" + + suspend fun fetchHomeVideoRows(): List { + val doc: Document = Jsoup.connect(JcyConst.HOME_URL) + .header(HttpHeaders.REFERER, JcyConst.HOME_URL) + .header(HttpHeaders.USER_AGENT, JcyConst.CHROME_USER_AGENT) + .cookie(JcyRotateCaptchaTool.COOKIE_GUARD_OK, getGuardOk()) + .get() + checkIfNeedValidateCaptcha(doc.head()) + return JcyHtmlParserTool.parseHomVideoRows(doc.body()) + } + + suspend fun fetchRankList(): List { + val doc: Document = Jsoup.connect(JcyConst.RANK_URL) + .header(HttpHeaders.REFERER, JcyConst.RANK_URL) + .header(HttpHeaders.USER_AGENT, JcyConst.CHROME_USER_AGENT) + .cookie(JcyRotateCaptchaTool.COOKIE_GUARD_OK, getGuardOk()) + .get() + checkIfNeedValidateCaptcha(doc.head()) + return JcyHtmlParserTool.parseRankList(doc.body()) + } + + suspend fun searchVideos(query: String): List { + val doc: Document = Jsoup.connect("${JcyConst.SEARCH_URL}$query") + .header(HttpHeaders.REFERER, JcyConst.RANK_URL) + .header(HttpHeaders.USER_AGENT, JcyConst.CHROME_USER_AGENT) + .cookie(JcyRotateCaptchaTool.COOKIE_GUARD_OK, getGuardOk()) + .get() + checkIfNeedValidateCaptcha(doc.head()) + val vodListEl = doc.body().selectFirst(".vod-list")!! + return JcyHtmlParserTool.parseVideoRow(vodListEl).list + } + + suspend fun catalog(queryMap: Map): List { + val query = queryMap.toSortedMap().map { + "/${it.key}/${it.value}" + }.joinToString("") + val doc: Document = Jsoup.connect(JcyConst.CATALOG_URL.replace("{query}", query)) + .header(HttpHeaders.REFERER, JcyConst.RANK_URL) + .header(HttpHeaders.USER_AGENT, JcyConst.CHROME_USER_AGENT) + .cookie(JcyRotateCaptchaTool.COOKIE_GUARD_OK, getGuardOk()) + .get() + checkIfNeedValidateCaptcha(doc.head()) + val vodListEl = doc.body().selectFirst(".vod-list")!! + return JcyHtmlParserTool.parseVideoRow(vodListEl).list + } + + suspend fun fetchVideoDetail(id: Long): JcyVideoDetail { + val url = JcyConst.DETAIL_URL.replace("{id}", id.toString()) + val doc: Document = Jsoup.connect(url) + .header(HttpHeaders.REFERER, url) + .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) + .cookie(JcyRotateCaptchaTool.COOKIE_GUARD_OK, getGuardOk()) + .get() + checkIfNeedValidateCaptcha(doc.head()) + return JcyHtmlParserTool.parseVideoDetail(doc.body()) + } + + private fun checkIfNeedValidateCaptcha(body: Element) { + if (JcyRotateCaptchaTool.checkIfNeedValidateCaptcha(body)) + throw NeedValidateCaptchaException() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/nav/AppNavigation.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/AppNavigation.kt similarity index 84% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/nav/AppNavigation.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/AppNavigation.kt index d85206f..d0e3999 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/nav/AppNavigation.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/AppNavigation.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.nav +package com.muedsa.jcytv.screens import androidx.compose.runtime.Composable import androidx.hilt.navigation.compose.hiltViewModel @@ -13,9 +13,10 @@ import com.muedsa.compose.tv.LocalRightSideDrawerControllerProvider import com.muedsa.compose.tv.widget.FullWidthDialogProperties import com.muedsa.compose.tv.widget.RightSideDrawerWithNavController import com.muedsa.compose.tv.widget.RightSideDrawerWithNavDrawerContent -import com.muedsa.jcytv.ui.features.detail.AnimeDetailScreen -import com.muedsa.jcytv.ui.features.home.HomeNavScreen -import com.muedsa.jcytv.ui.features.setting.AppSettingScreen +import com.muedsa.jcytv.screens.captcha.CaptchaScreen +import com.muedsa.jcytv.screens.detail.AnimeDetailScreen +import com.muedsa.jcytv.screens.home.HomeNavScreen +import com.muedsa.jcytv.screens.setting.AppSettingScreen @Composable fun AppNavigation() { @@ -38,7 +39,7 @@ fun AppNavigation() { ) { HomeNavScreen( tabIndex = checkNotNull(it.arguments?.getInt("tabIndex")), - homePageViewModel = hiltViewModel(viewModelStoreOwner), + mainScreenViewModel = hiltViewModel(viewModelStoreOwner), ) } @@ -64,6 +65,13 @@ fun AppNavigation() { controller = drawerController ) } + + dialog( + route = NavigationItems.Captcha.route, + dialogProperties = FullWidthDialogProperties() + ) { + CaptchaScreen() + } } } } diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/nav/NavigationItems.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/NavigationItems.kt similarity index 88% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/nav/NavigationItems.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/NavigationItems.kt index 8a7a043..c194e66 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/nav/NavigationItems.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/NavigationItems.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.nav +package com.muedsa.jcytv.screens import androidx.navigation.NamedNavArgument import androidx.navigation.NavType @@ -25,4 +25,6 @@ sealed class NavigationItems( data object Setting : NavigationItems("setting") data object RightSideDrawer : NavigationItems("right_side_drawer") + + data object Captcha : NavigationItems("captcha") } \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaScreen.kt new file mode 100644 index 0000000..439e159 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaScreen.kt @@ -0,0 +1,204 @@ +package com.muedsa.jcytv.screens.captcha + +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.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.material3.LinearProgressIndicator +import androidx.compose.runtime.Composable +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.draw.drawWithCache +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil.ImageLoader +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.muedsa.compose.tv.useLocalErrorMsgBoxController +import com.muedsa.compose.tv.useLocalNavHostController +import com.muedsa.compose.tv.widget.onDpadKeyEvents +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate +import com.muedsa.jcytv.util.JcyRotateCaptchaTool +import com.muedsa.uitl.LogUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import java.io.IOException + + +const val ONCE_ROTATE_DEGREES = 3f + +@Composable +fun CaptchaScreen( + captchaViewModel: CaptchaViewModel = hiltViewModel() +) { + + val navHostController = useLocalNavHostController() + val errorMsgBoxController = useLocalErrorMsgBoxController() + + val uiState by captchaViewModel.uiState.collectAsStateWithLifecycle() + + var imageUrl by remember { mutableStateOf(JcyRotateCaptchaTool.buildCaptchaImageUrl()) } + var degrees by remember { mutableFloatStateOf(0f) } + + val context = LocalContext.current + val configuration = LocalConfiguration.current + val imageSize = remember { (configuration.screenHeightDp / 3).dp } + + val imageLoader = remember { + ImageLoader.Builder(context) + .okHttpClient { + OkHttpClient.Builder() + .addInterceptor(GetGuardFromRespHeaderInterceptor(captchaViewModel)) + .build() + } + .build() + } + + LaunchedEffect(uiState) { + if (uiState is CaptchaState.Success) { + navigateAfterValid(navHostController) + } + } + + Column( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("请先完成图片旋转验证", style = MaterialTheme.typography.displayMedium) + Spacer(Modifier.height(20.dp)) + Box(modifier = Modifier + .size(imageSize) + .clip(CircleShape) + .focusable(true) + .onDpadKeyEvents( + onLeft = { + val d = degrees - ONCE_ROTATE_DEGREES + degrees = if (d < 0f) 0f else d + return@onDpadKeyEvents true + }, + onRight = { + val d = degrees + ONCE_ROTATE_DEGREES + degrees = if (d > 360f) 360f else d + return@onDpadKeyEvents true + }, + onCenter = { + LogUtil.d("captcha image degrees = $degrees") + if (uiState is CaptchaState.Ready) { + captchaViewModel.validate((uiState as CaptchaState.Ready).guard, degrees) + } else if (uiState is CaptchaState.Retry) { + imageUrl = JcyRotateCaptchaTool.buildCaptchaImageUrl() + degrees = 0f + } + return@onDpadKeyEvents true + } + ) + .drawWithCache { + val widthHalf = size.width / 2 + val heightHalf = size.height / 2 + val strokeWidth = 2.dp.toPx() + val pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 3f), 0f) + onDrawWithContent { + drawContent() + drawLine( + Color.Red, + start = Offset(widthHalf, 0f), + end = Offset(widthHalf, size.height), + strokeWidth = strokeWidth, + pathEffect = pathEffect + ) + drawLine( + Color.Red, + Offset(0f, heightHalf), + Offset(size.width, heightHalf), + strokeWidth = strokeWidth, + pathEffect = pathEffect + ) + } + } + ) { + if (uiState !is CaptchaState.Validating) { + SubcomposeAsyncImage( + modifier = Modifier + .fillMaxSize() + .rotate(degrees), + imageLoader = imageLoader, + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .listener( + onError = { _, result -> + LogUtil.fb(result.throwable, "loading captcha image error") + errorMsgBoxController.error("加载图片失败") + captchaViewModel.error() + } + ) + .build(), + contentDescription = null, + contentScale = ContentScale.FillBounds + ) + } + } + Spacer(Modifier.height(20.dp)) + LinearProgressIndicator( + progress = { degrees / 360f }, + modifier = Modifier.width(imageSize), + gapSize = 0.dp + ) + Spacer(Modifier.height(20.dp)) + Text( + text = if (uiState is CaptchaState.Retry) "加载或验证失败, 请点击确认键重新加载验证码" + else "请用方向键旋转图片角度为正, 按确认键进行提交", + color = if (uiState is CaptchaState.Retry) Color.Red else Color.White + ) + } +} + +suspend fun navigateAfterValid(navHostController: NavHostController) { + withContext(Dispatchers.Main) { + if(!navHostController.popBackStack()) { + navHostController.navigate(NavigationItems.Home, listOf("0")) + } + } +} + +internal class GetGuardFromRespHeaderInterceptor( + private val captchaViewModel: CaptchaViewModel +) : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val response: Response = chain.proceed(chain.request()) + captchaViewModel.ready(guard = JcyRotateCaptchaTool + .getSetCookieValueFromHeaders(response.headers, JcyRotateCaptchaTool.COOKIE_GUARD)) + return response + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaViewModel.kt new file mode 100644 index 0000000..83caa7a --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/captcha/CaptchaViewModel.kt @@ -0,0 +1,77 @@ +package com.muedsa.jcytv.screens.captcha + +import androidx.datastore.preferences.core.edit +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.muedsa.jcytv.KEY_CAPTCHA_GUARD_OK +import com.muedsa.jcytv.repository.DataStoreRepo +import com.muedsa.jcytv.screens.home.main.MainScreenUiState +import com.muedsa.jcytv.util.JcyRotateCaptchaTool +import com.muedsa.uitl.LogUtil +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class CaptchaViewModel @Inject constructor( + private val dataStoreRepo: DataStoreRepo +) : ViewModel() { + + private val internalUiState = MutableSharedFlow() + val uiState = internalUiState.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MainScreenUiState.Loading + ) + + fun ready(guard: String?) { + viewModelScope.launch { + if (guard.isNullOrEmpty()) { + internalUiState.emit(CaptchaState.Retry) + } else { + internalUiState.emit(CaptchaState.Ready(guard)) + } + } + } + + fun error() { + viewModelScope.launch { + internalUiState.emit(CaptchaState.Retry) + } + } + + fun validate(guard: String, degrees: Float) { + viewModelScope.launch { + internalUiState.emit(CaptchaState.Validating) + withContext(Dispatchers.IO) { + val state = try { + val guardOk = JcyRotateCaptchaTool.getGuardOk(guard, degrees) + if (!guardOk.isNullOrEmpty()) { + dataStoreRepo.dataStore.edit { + it[KEY_CAPTCHA_GUARD_OK] = guardOk + } + CaptchaState.Success + } else { + CaptchaState.Retry + } + } catch (throwable: Throwable) { + LogUtil.d(throwable) + CaptchaState.Retry + } + internalUiState.emit(state) + } + } + } +} + +sealed interface CaptchaState { + data object Validating: CaptchaState + data class Ready(val guard: String): CaptchaState + data object Retry: CaptchaState + data object Success: CaptchaState +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/AnimeDanmakuSelectorWidget.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDanmakuSelectorWidget.kt similarity index 98% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/AnimeDanmakuSelectorWidget.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDanmakuSelectorWidget.kt index 4a3ce07..83c3d7f 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/AnimeDanmakuSelectorWidget.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDanmakuSelectorWidget.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.detail +package com.muedsa.jcytv.screens.detail import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee @@ -38,7 +38,6 @@ import com.muedsa.compose.tv.theme.outline import com.muedsa.compose.tv.useLocalRightSideDrawerController import com.muedsa.compose.tv.widget.NoBackground import com.muedsa.compose.tv.widget.TwoSideWideButton -import com.muedsa.jcytv.viewmodel.AnimeDetailViewModel @Composable diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/AnimeDetailScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDetailScreen.kt similarity index 98% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/AnimeDetailScreen.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDetailScreen.kt index 1023902..2017c7d 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/AnimeDetailScreen.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDetailScreen.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.detail +package com.muedsa.jcytv.screens.detail import android.content.Intent import androidx.compose.foundation.basicMarquee @@ -64,12 +64,11 @@ import com.muedsa.compose.tv.widget.TwoSideWideButton import com.muedsa.compose.tv.widget.rememberScreenBackgroundState import com.muedsa.jcytv.PlaybackActivity import com.muedsa.jcytv.room.model.FavoriteAnimeModel -import com.muedsa.jcytv.ui.FavoriteIconColor -import com.muedsa.jcytv.ui.RankFontColor -import com.muedsa.jcytv.ui.RankIconColor -import com.muedsa.jcytv.ui.nav.NavigationItems -import com.muedsa.jcytv.ui.nav.navigate -import com.muedsa.jcytv.viewmodel.AnimeDetailViewModel +import com.muedsa.jcytv.theme.FavoriteIconColor +import com.muedsa.jcytv.theme.RankFontColor +import com.muedsa.jcytv.theme.RankIconColor +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate import com.muedsa.model.LazyType import com.muedsa.uitl.LogUtil @@ -346,6 +345,7 @@ fun AnimeDetailScreen( context.startActivity(intent) }, onError = { + LogUtil.fb(it) errorMsgBoxController.error(it) } ) diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/AnimeDetailViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDetailViewModel.kt similarity index 92% rename from app/src/main/kotlin/com/muedsa/jcytv/viewmodel/AnimeDetailViewModel.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDetailViewModel.kt index e68598f..5335355 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/AnimeDetailViewModel.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/AnimeDetailViewModel.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.viewmodel +package com.muedsa.jcytv.screens.detail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -7,12 +7,13 @@ import com.muedsa.jcytv.exception.DataRequestException import com.muedsa.jcytv.model.JcyVideoDetail import com.muedsa.jcytv.model.dandanplay.DanAnimeInfo import com.muedsa.jcytv.model.dandanplay.DanSearchAnime +import com.muedsa.jcytv.repository.JcyRepo import com.muedsa.jcytv.room.dao.EpisodeProgressDao import com.muedsa.jcytv.room.dao.FavoriteAnimeDao import com.muedsa.jcytv.room.model.FavoriteAnimeModel import com.muedsa.jcytv.service.DanDanPlayApiService -import com.muedsa.jcytv.ui.nav.NavigationItems -import com.muedsa.jcytv.util.JcyHtmlTool +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.util.JcyPlaySourceTool import com.muedsa.model.LazyData import com.muedsa.model.LazyType import com.muedsa.uitl.LogUtil @@ -32,6 +33,7 @@ import javax.inject.Inject @HiltViewModel class AnimeDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val jcyRepo: JcyRepo, private val danDanPlayApiService: DanDanPlayApiService, private val favoriteAnimeDao: FavoriteAnimeDao, private val episodeProgressDao: EpisodeProgressDao @@ -60,7 +62,7 @@ class AnimeDetailViewModel @Inject constructor( } else null }.stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), + started = SharingStarted.WhileSubscribed(5_000), initialValue = null ) @@ -74,7 +76,7 @@ class AnimeDetailViewModel @Inject constructor( } else null)?.associateBy({ it.title }, { it }) ?: emptyMap() }.stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyMap() ) @@ -89,7 +91,7 @@ class AnimeDetailViewModel @Inject constructor( private suspend fun fetchAnimeDetail(aid: Long): LazyData { return try { - LazyData.success(JcyHtmlTool.getVideoDetailById(aid)) + LazyData.success(jcyRepo.fetchVideoDetail(aid)) } catch (t: Throwable) { LogUtil.fb(t) LazyData.fail(t) @@ -158,11 +160,11 @@ class AnimeDetailViewModel @Inject constructor( ) { viewModelScope.launch(context = Dispatchers.IO) { try { - val rawPlaySource = JcyHtmlTool.getRawPlaySource( - JcyHtmlTool.getAbsoluteUrl(url) + val rawPlaySource = JcyPlaySourceTool.getRawPlaySource( + JcyPlaySourceTool.getAbsoluteUrl(url) ) LogUtil.fb("play source: $rawPlaySource") - val realPlayUrl = JcyHtmlTool.getRealPlayUrl(rawPlaySource) + val realPlayUrl = JcyPlaySourceTool.getRealPlayUrl(rawPlaySource) withContext(Dispatchers.Main) { onSuccess(realPlayUrl) } diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/EpisodeListWidget.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/EpisodeListWidget.kt similarity index 99% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/EpisodeListWidget.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/detail/EpisodeListWidget.kt index 5947a56..5a5a24f 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/detail/EpisodeListWidget.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/detail/EpisodeListWidget.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.detail +package com.muedsa.jcytv.screens.detail import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavScreen.kt similarity index 85% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavScreen.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavScreen.kt index cddcf06..36f161c 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavScreen.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavScreen.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.home +package com.muedsa.jcytv.screens.home import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -7,7 +7,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.muedsa.compose.tv.widget.ScreenBackground import com.muedsa.compose.tv.widget.ScreenBackgroundState import com.muedsa.compose.tv.widget.rememberScreenBackgroundState -import com.muedsa.jcytv.viewmodel.HomePageViewModel +import com.muedsa.jcytv.screens.home.main.MainScreenViewModel private val LocalHomeScreenBackgroundState = compositionLocalOf { null } @@ -27,7 +27,7 @@ fun useLocalHomeScreenBackgroundState(): ScreenBackgroundState { @Composable fun HomeNavScreen( tabIndex: Int = 0, - homePageViewModel: HomePageViewModel = hiltViewModel(), + mainScreenViewModel: MainScreenViewModel = hiltViewModel(), ) { val backgroundState = rememberScreenBackgroundState() @@ -35,7 +35,7 @@ fun HomeNavScreen( LocalHomeScreenBackgroundStateProvider(backgroundState) { HomeNavTabWidget( tabIndex = tabIndex, - homePageViewModel = homePageViewModel + mainScreenViewModel = mainScreenViewModel ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavTab.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavTab.kt similarity index 78% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavTab.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavTab.kt index 9160ddb..01c3f67 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavTab.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavTab.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.home +package com.muedsa.jcytv.screens.home enum class HomeNavTab(val title: String) { Main("首页"), diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavTabWidget.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavTabWidget.kt similarity index 88% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavTabWidget.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavTabWidget.kt index dd422f1..91807e7 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/HomeNavTabWidget.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/HomeNavTabWidget.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.home +package com.muedsa.jcytv.screens.home import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -24,12 +24,12 @@ import androidx.tv.material3.TabRow import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text import com.muedsa.compose.tv.widget.ScreenBackgroundType -import com.muedsa.jcytv.ui.features.home.catalog.CatalogScreen -import com.muedsa.jcytv.ui.features.home.favorites.FavoritesScreen -import com.muedsa.jcytv.ui.features.home.main.MainScreen -import com.muedsa.jcytv.ui.features.home.rank.RankScreen -import com.muedsa.jcytv.ui.features.home.search.SearchScreen -import com.muedsa.jcytv.viewmodel.HomePageViewModel +import com.muedsa.jcytv.screens.home.catalog.CatalogScreen +import com.muedsa.jcytv.screens.home.favorites.FavoritesScreen +import com.muedsa.jcytv.screens.home.main.MainScreen +import com.muedsa.jcytv.screens.home.rank.RankScreen +import com.muedsa.jcytv.screens.home.search.SearchScreen +import com.muedsa.jcytv.screens.home.main.MainScreenViewModel import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.milliseconds @@ -39,7 +39,7 @@ val tabs: Array = HomeNavTab.entries.toTypedArray() @Composable fun HomeNavTabWidget( tabIndex: Int = 0, - homePageViewModel: HomePageViewModel + mainScreenViewModel: MainScreenViewModel ) { val backgroundState = useLocalHomeScreenBackgroundState() var focusedTabIndex by rememberSaveable { mutableIntStateOf(tabIndex) } @@ -101,7 +101,7 @@ fun HomeNavTabWidget( } HomeContent( tabIndex = tabPanelIndex, - homePageViewModel = homePageViewModel + mainScreenViewModel = mainScreenViewModel ) } } @@ -109,14 +109,14 @@ fun HomeNavTabWidget( @Composable fun HomeContent( tabIndex: Int, - homePageViewModel: HomePageViewModel + mainScreenViewModel: MainScreenViewModel ) { val tab = tabs[tabIndex] when(tab) { HomeNavTab.Main -> { MainScreen( - viewModel = homePageViewModel + viewModel = mainScreenViewModel ) } HomeNavTab.Catalog -> CatalogScreen() diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/catalog/CatalogOptionsWidget.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogOptionsWidget.kt similarity index 97% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/catalog/CatalogOptionsWidget.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogOptionsWidget.kt index db41e65..a18ea13 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/catalog/CatalogOptionsWidget.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogOptionsWidget.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.home.catalog +package com.muedsa.jcytv.screens.home.catalog import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/catalog/CatalogScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogScreen.kt similarity index 96% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/catalog/CatalogScreen.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogScreen.kt index 3158a8f..9385ba3 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/catalog/CatalogScreen.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogScreen.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.home.catalog +package com.muedsa.jcytv.screens.home.catalog import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement @@ -49,12 +49,11 @@ import com.muedsa.compose.tv.useLocalNavHostController import com.muedsa.compose.tv.widget.CardType import com.muedsa.compose.tv.widget.ImageContentCard import com.muedsa.compose.tv.widget.ScreenBackgroundType -import com.muedsa.jcytv.ui.GirdLastItemHeight -import com.muedsa.jcytv.ui.VideoPosterSize -import com.muedsa.jcytv.ui.features.home.useLocalHomeScreenBackgroundState -import com.muedsa.jcytv.ui.nav.NavigationItems -import com.muedsa.jcytv.ui.nav.navigate -import com.muedsa.jcytv.viewmodel.CatalogViewModel +import com.muedsa.jcytv.theme.GirdLastItemHeight +import com.muedsa.jcytv.theme.VideoPosterSize +import com.muedsa.jcytv.screens.home.useLocalHomeScreenBackgroundState +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate import com.muedsa.model.LazyType import com.muedsa.uitl.LogUtil @@ -195,7 +194,7 @@ fun CatalogScreen( CatalogOptionsWidget( title = "排序", selectedKey = optionBy, - options =CatalogViewModel.ORDER_BY_OPTIONS, + options = CatalogViewModel.ORDER_BY_OPTIONS, onClick = { key, _ -> optionBy = if (optionBy == key) null else key viewModel.catalogNew() diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/CatalogViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogViewModel.kt similarity index 91% rename from app/src/main/kotlin/com/muedsa/jcytv/viewmodel/CatalogViewModel.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogViewModel.kt index 284b996..1d4c653 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/CatalogViewModel.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/catalog/CatalogViewModel.kt @@ -1,27 +1,28 @@ -package com.muedsa.jcytv.viewmodel +package com.muedsa.jcytv.screens.home.catalog import android.icu.util.Calendar import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.util.fastAny -import androidx.compose.ui.util.fastFirst import androidx.compose.ui.util.fastFirstOrNull import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.muedsa.jcytv.model.JcySimpleVideoInfo -import com.muedsa.jcytv.util.JcyHtmlTool +import com.muedsa.jcytv.repository.JcyRepo import com.muedsa.model.LazyPagedList import com.muedsa.uitl.LogUtil +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber import javax.inject.Inject import kotlin.math.max -class CatalogViewModel @Inject constructor() : ViewModel() { +@HiltViewModel +class CatalogViewModel @Inject constructor( + private val jcyRepo: JcyRepo +) : ViewModel() { + // TODO all ui state val optionIdState = mutableStateOf(ID_OPTIONS.keys.first()) val optionAreaState = mutableStateOf(null) val optionClassState = mutableStateOf(null) @@ -38,12 +39,10 @@ class CatalogViewModel @Inject constructor() : ViewModel() { val animeLPSF: StateFlow, JcySimpleVideoInfo>> = _animeLPSF fun catalog(lp: LazyPagedList, JcySimpleVideoInfo>) { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { val loadingLP = lp.loadingNext() _animeLPSF.value = loadingLP - _animeLPSF.value = withContext(Dispatchers.IO) { - fetchCatalog(loadingLP) - } + _animeLPSF.value = fetchCatalog(loadingLP) } } @@ -51,12 +50,12 @@ class CatalogViewModel @Inject constructor() : ViewModel() { catalog(LazyPagedList.new(buildQueryParams())) } - private fun fetchCatalog( + private suspend fun fetchCatalog( lp: LazyPagedList, JcySimpleVideoInfo> ): LazyPagedList, JcySimpleVideoInfo> { return try { val pageNum = lp.nextPage - JcyHtmlTool.catalog(lp.query.toMutableMap().apply { + jcyRepo.catalog(lp.query.toMutableMap().apply { this["page"] = pageNum.toString() }).let { videoList -> if (lp.list.isNotEmpty()) { diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/FavoriteViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/favorites/FavoriteViewModel.kt similarity index 87% rename from app/src/main/kotlin/com/muedsa/jcytv/viewmodel/FavoriteViewModel.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/favorites/FavoriteViewModel.kt index 4be8476..7ed6c66 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/FavoriteViewModel.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/favorites/FavoriteViewModel.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.viewmodel +package com.muedsa.jcytv.screens.home.favorites import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -17,7 +17,7 @@ class FavoriteViewModel @Inject constructor( val favoriteAnimeSF = dao.flowAll().stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/favorites/FavoritesScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/favorites/FavoritesScreen.kt similarity index 94% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/favorites/FavoritesScreen.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/favorites/FavoritesScreen.kt index c26c8c4..8833ef4 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/favorites/FavoritesScreen.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/favorites/FavoritesScreen.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.home.favorites +package com.muedsa.jcytv.screens.home.favorites import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column @@ -38,11 +38,10 @@ import com.muedsa.compose.tv.useLocalNavHostController import com.muedsa.compose.tv.widget.CardType import com.muedsa.compose.tv.widget.ImageContentCard import com.muedsa.compose.tv.widget.ScreenBackgroundType -import com.muedsa.jcytv.ui.VideoPosterSize -import com.muedsa.jcytv.ui.features.home.useLocalHomeScreenBackgroundState -import com.muedsa.jcytv.ui.nav.NavigationItems -import com.muedsa.jcytv.ui.nav.navigate -import com.muedsa.jcytv.viewmodel.FavoriteViewModel +import com.muedsa.jcytv.theme.VideoPosterSize +import com.muedsa.jcytv.screens.home.useLocalHomeScreenBackgroundState +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate import com.muedsa.uitl.LogUtil diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreen.kt new file mode 100644 index 0000000..1839099 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreen.kt @@ -0,0 +1,214 @@ +package com.muedsa.jcytv.screens.home.main + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +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.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.muedsa.compose.tv.model.ContentModel +import com.muedsa.compose.tv.theme.ImageCardRowCardPadding +import com.muedsa.compose.tv.theme.ScreenPaddingLeft +import com.muedsa.compose.tv.useLocalErrorMsgBoxController +import com.muedsa.compose.tv.useLocalNavHostController +import com.muedsa.compose.tv.widget.ContentBlock +import com.muedsa.compose.tv.widget.ErrorScreen +import com.muedsa.compose.tv.widget.ImageCardsRow +import com.muedsa.compose.tv.widget.ImmersiveList +import com.muedsa.compose.tv.widget.LoadingScreen +import com.muedsa.compose.tv.widget.ScreenBackgroundType +import com.muedsa.compose.tv.widget.StandardImageCardsRow +import com.muedsa.jcytv.BuildConfig +import com.muedsa.jcytv.exception.NeedValidateCaptchaException +import com.muedsa.jcytv.model.JcyVideoRow +import com.muedsa.jcytv.theme.VideoPosterSize +import com.muedsa.jcytv.screens.home.useLocalHomeScreenBackgroundState +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate +import com.muedsa.uitl.LogUtil + + +@Composable +fun MainScreen( + viewModel: MainScreenViewModel = hiltViewModel(), +) { + val errorMsgBoxController = useLocalErrorMsgBoxController() + val navController = useLocalNavHostController() + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val s = uiState) { + is MainScreenUiState.Loading -> LoadingScreen() + + is MainScreenUiState.Error -> ErrorScreen( + onError = { + if (s.exception is NeedValidateCaptchaException) { + navController.navigate(NavigationItems.Captcha) + } else { + errorMsgBoxController.error(s.error) + } + }, + onRefresh = { viewModel.refreshData() } + ) + + is MainScreenUiState.Ready -> { + if (s.rows.isNotEmpty()) { + MainScreenVideoRows(s.rows[0], s.rows.subList(1, s.rows.size)) + } else { + ErrorScreen { viewModel.refreshData() } + } + } + } +} + +@Composable +fun MainScreenVideoRows( + first: JcyVideoRow, + others: List +) { + val configuration = LocalConfiguration.current + val backgroundState = useLocalHomeScreenBackgroundState() + val navController = useLocalNavHostController() + + + val firstRowHeight = + (MaterialTheme.typography.titleLarge.fontSize.value * configuration.fontScale + 0.5f).dp + + ImageCardRowCardPadding * 3 + VideoPosterSize.height + + val tabHeight = + (MaterialTheme.typography.labelLarge.fontSize.value * configuration.fontScale + 0.5f).dp + + 24.dp * 2 + + 6.dp * 2 + + val screenHeight = configuration.screenHeightDp.dp + val screenWidth = configuration.screenWidthDp.dp + + LazyColumn( + modifier = Modifier + .padding(start = ScreenPaddingLeft - ImageCardRowCardPadding) + ) { + item { + var title by remember { mutableStateOf("") } + var subTitle by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = first) { + val firstAnime = first.list.firstOrNull() + if (firstAnime != null) { + title = firstAnime.title + subTitle = firstAnime.subTitle + backgroundState.url = firstAnime.imageUrl + backgroundState.type = ScreenBackgroundType.SCRIM + } + } + ImmersiveList( + background = { + ContentBlock( + modifier = Modifier + .width(screenWidth / 2) + .height(screenHeight - firstRowHeight - tabHeight - 20.dp), + model = ContentModel(title = title, subtitle = subTitle), + descriptionMaxLines = 3 + ) + }, + ) { + Column { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(screenHeight - firstRowHeight - tabHeight) + ) + ImageCardsRow( + modifier = Modifier.testTag("mainScreen_row_1"), + title = first.title, + modelList = first.list, + imageFn = { _, anime -> + anime.imageUrl + }, + imageSize = VideoPosterSize, + onItemFocus = { _, anime -> + title = anime.title + subTitle = anime.subTitle + backgroundState.type = ScreenBackgroundType.SCRIM + backgroundState.url = anime.imageUrl + }, + onItemClick = { _, anime -> + LogUtil.d("Click $anime") + navController.navigate( + NavigationItems.Detail, + listOf(anime.id.toString()) + ) + } + ) + } + } + } + + others.forEachIndexed { index, row -> + item { + StandardImageCardsRow( + modifier = Modifier.testTag("mainScreen_row_${index + 1}"), + title = row.title, + modelList = row.list, + imageFn = { _, anime -> + anime.imageUrl + }, + imageSize = VideoPosterSize, + contentFn = { _, anime -> + ContentModel( + title = anime.title, + subtitle = anime.subTitle + ) + }, + onItemFocus = { _, anime -> + backgroundState.type = ScreenBackgroundType.BLUR + backgroundState.url = anime.imageUrl + }, + onItemClick = { _, anime -> + LogUtil.d("Click $anime") + navController.navigate( + NavigationItems.Detail, + listOf(anime.id.toString()) + ) + } + ) + } + } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + Text( + modifier = Modifier + .padding(top = 16.dp) + .align(Alignment.CenterEnd) + .graphicsLayer { alpha = 0.6f }, + text = "APP版本: ${BuildConfig.BUILD_TYPE}-${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreenViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreenViewModel.kt new file mode 100644 index 0000000..8c7fc01 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/main/MainScreenViewModel.kt @@ -0,0 +1,51 @@ +package com.muedsa.jcytv.screens.home.main + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.muedsa.jcytv.model.JcyVideoRow +import com.muedsa.jcytv.repository.JcyRepo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainScreenViewModel @Inject constructor( + private val jcyRepo: JcyRepo +) : ViewModel() { + + private val internalUiState = MutableSharedFlow() + val uiState = internalUiState.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MainScreenUiState.Loading + ) + + fun refreshData() { + viewModelScope.launch(Dispatchers.IO) { + internalUiState.emit(MainScreenUiState.Loading) + val state = try { + val rows = jcyRepo.fetchHomeVideoRows() + MainScreenUiState.Ready(rows) + } catch (throwable: Throwable) { + MainScreenUiState.Error(throwable.message ?: "error", throwable) + } + internalUiState.emit(state) + } + } + + init { + refreshData() + } +} + +@Immutable +sealed interface MainScreenUiState { + data object Loading: MainScreenUiState + data class Error(val error: String, val exception: Throwable? = null): MainScreenUiState + data class Ready(val rows: List): MainScreenUiState +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/rank/RankAnimeWidget.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankItemWidget.kt similarity index 97% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/rank/RankAnimeWidget.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankItemWidget.kt index ee68046..2473d66 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/rank/RankAnimeWidget.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankItemWidget.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.home.rank +package com.muedsa.jcytv.screens.home.rank import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.interaction.MutableInteractionSource @@ -21,7 +21,7 @@ import com.muedsa.compose.tv.conditional import com.muedsa.jcytv.model.JcyRankVideoInfo @Composable -fun RankAnimeWidget( +fun RankItemWidget( model: JcyRankVideoInfo, onClick: () -> Unit = {} ) { diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreen.kt new file mode 100644 index 0000000..f1a066a --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreen.kt @@ -0,0 +1,43 @@ +package com.muedsa.jcytv.screens.home.rank + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.muedsa.compose.tv.useLocalErrorMsgBoxController +import com.muedsa.compose.tv.useLocalNavHostController +import com.muedsa.compose.tv.widget.ErrorScreen +import com.muedsa.compose.tv.widget.LoadingScreen +import com.muedsa.jcytv.exception.NeedValidateCaptchaException +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate + + +@Composable +fun RankScreen( + viewModel: RankViewModel = hiltViewModel() +) { + val errorMsgBoxController = useLocalErrorMsgBoxController() + val navController = useLocalNavHostController() + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val s = uiState) { + is RankScreenUiState.Loading -> LoadingScreen() + + is RankScreenUiState.Error -> ErrorScreen( + onError = { + if (s.exception is NeedValidateCaptchaException) { + navController.navigate(NavigationItems.Captcha) + } else { + errorMsgBoxController.error(s.error) + } + }, + onRefresh = { + viewModel.fetchRankList() + } + ) + + is RankScreenUiState.Ready -> RankWidget(list = s.rankList) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreenViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreenViewModel.kt new file mode 100644 index 0000000..1fdc086 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankScreenViewModel.kt @@ -0,0 +1,51 @@ +package com.muedsa.jcytv.screens.home.rank + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.muedsa.jcytv.model.JcyRankList +import com.muedsa.jcytv.repository.JcyRepo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RankViewModel @Inject constructor( + private val jcyRepo: JcyRepo +) : ViewModel() { + + private val internalUiState = MutableSharedFlow() + val uiState = internalUiState.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RankScreenUiState.Loading + ) + + fun fetchRankList() { + viewModelScope.launch(Dispatchers.IO) { + internalUiState.emit(RankScreenUiState.Loading) + val state = try { + val list = jcyRepo.fetchRankList() + RankScreenUiState.Ready(list) + } catch (throwable: Throwable) { + RankScreenUiState.Error(throwable.message ?: "error", throwable) + } + internalUiState.emit(state) + } + } + + init { + fetchRankList() + } +} + +@Immutable +sealed interface RankScreenUiState { + data object Loading : RankScreenUiState + data class Error(val error: String, val exception: Throwable? = null) : RankScreenUiState + data class Ready(val rankList: List) : RankScreenUiState +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankWidget.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankWidget.kt new file mode 100644 index 0000000..2983cf4 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/rank/RankWidget.kt @@ -0,0 +1,70 @@ +package com.muedsa.jcytv.screens.home.rank + +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.muedsa.compose.tv.theme.ScreenPaddingLeft +import com.muedsa.compose.tv.useLocalNavHostController +import com.muedsa.jcytv.model.JcyRankList +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate +import com.muedsa.uitl.LogUtil +import kotlin.math.min + +@Composable +fun RankWidget( + list: List +) { + val navController = useLocalNavHostController() + + Column(modifier = Modifier.padding(start = ScreenPaddingLeft)) { + val ranks = list.subList(0, min(3, list.size)) + Row { + ranks.forEachIndexed { index, rank -> + Column( + modifier = Modifier + .padding(top = 10.dp, bottom = 10.dp, end = 10.dp) + .weight(1f) + .testTag("rankScreen_column_$index") + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = rank.title, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge + ) + LazyColumn { + items(rank.list) { + RankItemWidget( + model = it, + onClick = { + LogUtil.d("Click $it") + navController.navigate( + NavigationItems.Detail, + listOf(it.id.toString()) + ) + } + ) + } + item { + Spacer(modifier = Modifier.height(100.dp)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchInput.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchInput.kt new file mode 100644 index 0000000..9f5f0f4 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchInput.kt @@ -0,0 +1,80 @@ +package com.muedsa.jcytv.screens.home.search + +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.testTag +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.OutlinedIconButton +import com.muedsa.compose.tv.theme.ScreenPaddingLeft +import com.muedsa.compose.tv.theme.outline + +@Composable +fun SearchInput( + searching: Boolean, + onSearch: (String) -> Unit +) { + var searchText by remember { mutableStateOf("") } + Row( + modifier = Modifier + .fillMaxWidth() + .offset(x = -ScreenPaddingLeft) + .padding(vertical = 30.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth(0.55f) + .background( + color = MaterialTheme.colorScheme.inverseOnSurface, + shape = OutlinedTextFieldDefaults.shape + ), + textStyle = MaterialTheme.typography.bodyLarge, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.outline, + cursorColor = MaterialTheme.colorScheme.onSurface, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + value = searchText, + onValueChange = { + searchText = it + }, + singleLine = true, + enabled = !searching + ) + Spacer(modifier = Modifier.width(16.dp)) + OutlinedIconButton( + modifier = Modifier.testTag("searchScreen_searchButton"), + onClick = { onSearch(searchText) }, + enabled = !searching + ) { + Icon( + modifier = Modifier.size(ButtonDefaults.IconSize), + imageVector = Icons.Outlined.Search, + contentDescription = "搜索" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchResult.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchResult.kt new file mode 100644 index 0000000..83fabb9 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchResult.kt @@ -0,0 +1,72 @@ +package com.muedsa.jcytv.screens.home.search + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +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.foundation.lazy.grid.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.muedsa.compose.tv.model.ContentModel +import com.muedsa.compose.tv.theme.ImageCardRowCardPadding +import com.muedsa.compose.tv.useLocalNavHostController +import com.muedsa.compose.tv.widget.CardType +import com.muedsa.compose.tv.widget.ImageContentCard +import com.muedsa.compose.tv.widget.ScreenBackgroundType +import com.muedsa.jcytv.model.JcySimpleVideoInfo +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.home.useLocalHomeScreenBackgroundState +import com.muedsa.jcytv.screens.navigate +import com.muedsa.jcytv.theme.GirdLastItemHeight +import com.muedsa.jcytv.theme.VideoPosterSize +import com.muedsa.uitl.LogUtil + +@Composable +fun SearchResult( + animeList: List +) { + val backgroundState = useLocalHomeScreenBackgroundState() + val navController = useLocalNavHostController() + + LazyVerticalGrid( + columns = GridCells.Adaptive(VideoPosterSize.width + ImageCardRowCardPadding), + contentPadding = PaddingValues( + top = ImageCardRowCardPadding, + bottom = ImageCardRowCardPadding + ) + ) { + itemsIndexed( + items = animeList, + key = { _, item -> item.id } + ) { _, item -> + ImageContentCard( + modifier = Modifier.padding(end = ImageCardRowCardPadding), + url = item.imageUrl, + imageSize = VideoPosterSize, + type = CardType.STANDARD, + model = ContentModel( + title = item.title, + subtitle = item.subTitle + ), + onItemFocus = { + backgroundState.url = item.imageUrl + backgroundState.type = ScreenBackgroundType.BLUR + }, + onItemClick = { + LogUtil.fb("Click $item") + navController.navigate( + NavigationItems.Detail, + listOf(item.id.toString()) + ) + } + ) + } + + // 最后一行占位 + item { + Spacer(modifier = Modifier.height(GirdLastItemHeight)) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchScreen.kt new file mode 100644 index 0000000..24483b8 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchScreen.kt @@ -0,0 +1,50 @@ +package com.muedsa.jcytv.screens.home.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.muedsa.compose.tv.theme.ScreenPaddingLeft +import com.muedsa.compose.tv.useLocalErrorMsgBoxController +import com.muedsa.compose.tv.useLocalNavHostController +import com.muedsa.compose.tv.widget.ErrorScreen +import com.muedsa.compose.tv.widget.LoadingScreen +import com.muedsa.jcytv.exception.NeedValidateCaptchaException +import com.muedsa.jcytv.screens.NavigationItems +import com.muedsa.jcytv.screens.navigate + + +@Composable +fun SearchScreen( + viewModel: SearchViewModel = hiltViewModel() +) { + + val errorMsgBoxController = useLocalErrorMsgBoxController() + val navController = useLocalNavHostController() + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Column(modifier = Modifier.padding(start = ScreenPaddingLeft)) { + SearchInput(searching = uiState is SearchScreenUiState.Searching) { viewModel.searchAnime(it) } + + when (val s = uiState) { + is SearchScreenUiState.Searching -> LoadingScreen() + + is SearchScreenUiState.Error -> ErrorScreen( + onError = { + if (s.exception is NeedValidateCaptchaException) { + navController.navigate(NavigationItems.Captcha) + } else { + errorMsgBoxController.error(s.error) + } + } + ) + + is SearchScreenUiState.Done -> SearchResult(s.animeList) + } + } +} + diff --git a/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchViewModel.kt new file mode 100644 index 0000000..87ab3f0 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/home/search/SearchViewModel.kt @@ -0,0 +1,49 @@ +package com.muedsa.jcytv.screens.home.search + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.muedsa.jcytv.model.JcySimpleVideoInfo +import com.muedsa.jcytv.repository.JcyRepo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val jcyRepo: JcyRepo +) : ViewModel() { + + private val internalUiState = MutableSharedFlow() + val uiState = internalUiState.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SearchScreenUiState.Done(emptyList()) + ) + + fun searchAnime(query: String) { + if (query.isNotBlank()) { + viewModelScope.launch(Dispatchers.IO) { + internalUiState.emit(SearchScreenUiState.Searching) + val state = try { + val list = jcyRepo.searchVideos(query) + SearchScreenUiState.Done(list) + } catch (throwable: Throwable) { + SearchScreenUiState.Error(throwable.message ?: "error", throwable) + } + internalUiState.emit(state) + } + } + } +} + +@Immutable +sealed interface SearchScreenUiState { + data object Searching : SearchScreenUiState + data class Error(val error: String, val exception: Throwable? = null) : SearchScreenUiState + data class Done(val animeList: List) : SearchScreenUiState +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/playback/PlaybackScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/playback/PlaybackScreen.kt similarity index 98% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/playback/PlaybackScreen.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/playback/PlaybackScreen.kt index c489a11..09fbee0 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/playback/PlaybackScreen.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/playback/PlaybackScreen.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.playback +package com.muedsa.jcytv.screens.playback import android.app.Activity import androidx.annotation.OptIn @@ -25,7 +25,6 @@ import com.muedsa.compose.tv.useLocalErrorMsgBoxController import com.muedsa.compose.tv.widget.player.DanmakuVideoPlayer import com.muedsa.compose.tv.widget.player.mergeDanmaku import com.muedsa.jcytv.BuildConfig -import com.muedsa.jcytv.viewmodel.PlaybackViewModel import com.muedsa.model.LazyType import com.muedsa.uitl.LogUtil import kotlinx.coroutines.delay diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/PlaybackViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/playback/PlaybackViewModel.kt similarity index 97% rename from app/src/main/kotlin/com/muedsa/jcytv/viewmodel/PlaybackViewModel.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/playback/PlaybackViewModel.kt index 5b36dcd..e23ec4f 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/PlaybackViewModel.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/playback/PlaybackViewModel.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.viewmodel +package com.muedsa.jcytv.screens.playback import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -41,7 +41,7 @@ class PlaybackViewModel @Inject constructor( } .stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), + started = SharingStarted.WhileSubscribed(5_000), initialValue = LazyData.init() ) diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/setting/AppSettingScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/setting/AppSettingScreen.kt similarity index 94% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/features/setting/AppSettingScreen.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/setting/AppSettingScreen.kt index 7e7dfb7..dc7df8e 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/setting/AppSettingScreen.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/setting/AppSettingScreen.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui.features.setting +package com.muedsa.jcytv.screens.setting import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -15,14 +15,13 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Remove import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.OutlinedIconButton @@ -30,27 +29,18 @@ import androidx.tv.material3.Surface import androidx.tv.material3.SurfaceDefaults import androidx.tv.material3.Text import com.muedsa.compose.tv.theme.surfaceContainer -import com.muedsa.compose.tv.useLocalErrorMsgBoxController import com.muedsa.compose.tv.widget.FocusScaleSwitch import com.muedsa.jcytv.BuildConfig -import com.muedsa.jcytv.viewmodel.AppSettingViewModel -import com.muedsa.model.LazyType +import com.muedsa.jcytv.model.AppSettingModel @Composable fun AppSettingScreen( viewModel: AppSettingViewModel = hiltViewModel() ) { - val errorMsgBoxController = useLocalErrorMsgBoxController() - val settingLD by viewModel.settingLDSF.collectAsState() + val model by viewModel.settingStateFlow.collectAsStateWithLifecycle() - LaunchedEffect(key1 = settingLD) { - if (settingLD.type == LazyType.FAILURE) { - errorMsgBoxController.error(settingLD.error) - } - } - - if (settingLD.type == LazyType.SUCCESS && settingLD.data != null) { - val settingModel = settingLD.data!! + if (model != null) { + val settingModel: AppSettingModel = model!! Column( modifier = Modifier .fillMaxHeight() diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/AppSettingViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/screens/setting/AppSettingViewModel.kt similarity index 80% rename from app/src/main/kotlin/com/muedsa/jcytv/viewmodel/AppSettingViewModel.kt rename to app/src/main/kotlin/com/muedsa/jcytv/screens/setting/AppSettingViewModel.kt index f281c2d..a9298f1 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/AppSettingViewModel.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/screens/setting/AppSettingViewModel.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.viewmodel +package com.muedsa.jcytv.screens.setting import androidx.datastore.preferences.core.edit import androidx.lifecycle.ViewModel @@ -10,13 +10,10 @@ import com.muedsa.jcytv.KEY_DANMAKU_SCREEN_PART import com.muedsa.jcytv.KEY_DANMAKU_SIZE_SCALE import com.muedsa.jcytv.model.AppSettingModel import com.muedsa.jcytv.repository.DataStoreRepo -import com.muedsa.model.LazyData -import com.muedsa.uitl.LogUtil import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -27,20 +24,14 @@ class AppSettingViewModel @Inject constructor( private val repo: DataStoreRepo ) : ViewModel() { - val settingLDSF: StateFlow> = repo.dataStore.data + val settingStateFlow: StateFlow = repo.dataStore.data .map { prefs -> - AppSettingModel.fromPreferences(prefs).let { model -> - LazyData.success(model) - } - } - .catch { - LogUtil.d(it) - emit(LazyData.fail(it)) + AppSettingModel.fromPreferences(prefs) } .stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = LazyData.init() + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null ) fun changeDanmakuEnable(enable: Boolean) { diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/Colors.kt b/app/src/main/kotlin/com/muedsa/jcytv/theme/Colors.kt similarity index 83% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/Colors.kt rename to app/src/main/kotlin/com/muedsa/jcytv/theme/Colors.kt index 80ac19b..3273b34 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/Colors.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/theme/Colors.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui +package com.muedsa.jcytv.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/Size.kt b/app/src/main/kotlin/com/muedsa/jcytv/theme/Size.kt similarity index 85% rename from app/src/main/kotlin/com/muedsa/jcytv/ui/Size.kt rename to app/src/main/kotlin/com/muedsa/jcytv/theme/Size.kt index 6e99f37..2ec8377 100644 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/Size.kt +++ b/app/src/main/kotlin/com/muedsa/jcytv/theme/Size.kt @@ -1,4 +1,4 @@ -package com.muedsa.jcytv.ui +package com.muedsa.jcytv.theme import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/main/MainScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/main/MainScreen.kt deleted file mode 100644 index 7374323..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/main/MainScreen.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.muedsa.jcytv.ui.features.home.main - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -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.lazy.LazyColumn -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.muedsa.compose.tv.model.ContentModel -import com.muedsa.compose.tv.theme.ImageCardRowCardPadding -import com.muedsa.compose.tv.theme.ScreenPaddingLeft -import com.muedsa.compose.tv.useLocalErrorMsgBoxController -import com.muedsa.compose.tv.useLocalNavHostController -import com.muedsa.compose.tv.widget.ContentBlock -import com.muedsa.compose.tv.widget.ErrorScreen -import com.muedsa.compose.tv.widget.ImageCardsRow -import com.muedsa.compose.tv.widget.ImmersiveList -import com.muedsa.compose.tv.widget.LoadingScreen -import com.muedsa.compose.tv.widget.ScreenBackgroundType -import com.muedsa.compose.tv.widget.StandardImageCardsRow -import com.muedsa.jcytv.BuildConfig -import com.muedsa.jcytv.ui.VideoPosterSize -import com.muedsa.jcytv.ui.features.home.useLocalHomeScreenBackgroundState -import com.muedsa.jcytv.ui.nav.NavigationItems -import com.muedsa.jcytv.ui.nav.navigate -import com.muedsa.jcytv.viewmodel.HomePageViewModel -import com.muedsa.model.LazyType -import com.muedsa.uitl.LogUtil - - -@Composable -fun MainScreen( - viewModel: HomePageViewModel = hiltViewModel(), -) { - val configuration = LocalConfiguration.current - - val backgroundState = useLocalHomeScreenBackgroundState() - val errorMsgBoxState = useLocalErrorMsgBoxController() - val navController = useLocalNavHostController() - - val firstRowHeight = - (MaterialTheme.typography.titleLarge.fontSize.value * configuration.fontScale + 0.5f).dp + - ImageCardRowCardPadding * 3 + VideoPosterSize.height - - val tabHeight = - (MaterialTheme.typography.labelLarge.fontSize.value * configuration.fontScale + 0.5f).dp + - 24.dp * 2 + - 6.dp * 2 - - val screenHeight = configuration.screenHeightDp.dp - val screenWidth = configuration.screenWidthDp.dp - - val homeRowsLD by viewModel.homeRowsSF.collectAsState() - - LaunchedEffect(key1 = homeRowsLD.type, key2 = homeRowsLD.error) { - if (homeRowsLD.type == LazyType.FAILURE) { - errorMsgBoxState.error(homeRowsLD.error) - } - } - - if (homeRowsLD.type == LazyType.SUCCESS && !homeRowsLD.data.isNullOrEmpty()) { - val homeRows = homeRowsLD.data!! - LazyColumn( - modifier = Modifier - .padding(start = ScreenPaddingLeft - ImageCardRowCardPadding) - ) { - item { - var title by remember { mutableStateOf("") } - var subTitle by remember { mutableStateOf(null) } - val firstRow = homeRows[0] - - LaunchedEffect(key1 = firstRow) { - val firstAnime = firstRow.second.firstOrNull() - if (firstAnime != null) { - title = firstAnime.title - subTitle = firstAnime.subTitle - backgroundState.url = firstAnime.imageUrl - backgroundState.type = ScreenBackgroundType.SCRIM - } - } - ImmersiveList( - background = { - ContentBlock( - modifier = Modifier - .width(screenWidth / 2) - .height(screenHeight - firstRowHeight - tabHeight - 20.dp), - model = ContentModel(title = title, subtitle = subTitle), - descriptionMaxLines = 3 - ) - }, - ) { - Column { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(screenHeight - firstRowHeight - tabHeight) - ) - ImageCardsRow( - modifier = Modifier.testTag("mainScreen_row_1"), - title = firstRow.first, - modelList = firstRow.second, - imageFn = { _, anime -> - anime.imageUrl - }, - imageSize = VideoPosterSize, - onItemFocus = { _, anime -> - title = anime.title - subTitle = anime.subTitle - backgroundState.type = ScreenBackgroundType.SCRIM - backgroundState.url = anime.imageUrl - }, - onItemClick = { _, anime -> - LogUtil.d("Click $anime") - navController.navigate( - NavigationItems.Detail, - listOf(anime.id.toString()) - ) - } - ) - } - } - } - - homeRows.subList(1, homeRows.size).forEachIndexed { index, row -> - item { - StandardImageCardsRow( - modifier = Modifier.testTag("mainScreen_row_${index + 1}"), - title = row.first, - modelList = row.second, - imageFn = { _, anime -> - anime.imageUrl - }, - imageSize = VideoPosterSize, - contentFn = { _, anime -> - ContentModel( - title = anime.title, - subtitle = anime.subTitle - ) - }, - onItemFocus = { _, anime -> - backgroundState.type = ScreenBackgroundType.BLUR - backgroundState.url = anime.imageUrl - }, - onItemClick = { _, anime -> - LogUtil.d("Click $anime") - navController.navigate( - NavigationItems.Detail, - listOf(anime.id.toString()) - ) - } - ) - } - } - - item { - Box(modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - ) { - Text( - modifier = Modifier - .padding(top = 16.dp) - .align(Alignment.CenterEnd) - .graphicsLayer { alpha = 0.6f }, - text = "APP版本: ${BuildConfig.BUILD_TYPE}-${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelSmall - ) - } - } - } - } else if (homeRowsLD.type == LazyType.LOADING) { - LoadingScreen() - } else { - ErrorScreen { - viewModel.refreshHomeData() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/rank/RankScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/rank/RankScreen.kt deleted file mode 100644 index b13139f..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/rank/RankScreen.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.muedsa.jcytv.ui.features.home.rank - -import androidx.compose.foundation.layout.Column -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.muedsa.compose.tv.theme.ScreenPaddingLeft -import com.muedsa.compose.tv.useLocalErrorMsgBoxController -import com.muedsa.compose.tv.useLocalNavHostController -import com.muedsa.compose.tv.widget.ErrorScreen -import com.muedsa.compose.tv.widget.LoadingScreen -import com.muedsa.jcytv.ui.nav.NavigationItems -import com.muedsa.jcytv.ui.nav.navigate -import com.muedsa.jcytv.viewmodel.RankViewModel -import com.muedsa.model.LazyType -import com.muedsa.uitl.LogUtil -import kotlin.math.min - - -@Composable -fun RankScreen( - viewModel: RankViewModel = hiltViewModel() -) { - val errorMsgBoxState = useLocalErrorMsgBoxController() - val navController = useLocalNavHostController() - - val rankListLD by viewModel.rankListLDSF.collectAsState() - - LaunchedEffect(key1 = rankListLD) { - if (rankListLD.type == LazyType.FAILURE) { - errorMsgBoxState.error(rankListLD.error) - } - } - - Column(modifier = Modifier.padding(start = ScreenPaddingLeft)) { - if (rankListLD.type == LazyType.SUCCESS && !rankListLD.data.isNullOrEmpty()) { - val ranks = rankListLD.data!!.subList(0, min(3, rankListLD.data!!.size)) - Row { - ranks.forEachIndexed { index, rank -> - Column( - modifier = Modifier - .padding(top = 10.dp, bottom = 10.dp, end = 10.dp) - .weight(1f) - .testTag("rankScreen_column_$index") - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = rank.first, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge - ) - LazyColumn { - items(rank.second) { - RankAnimeWidget( - model = it, - onClick = { - LogUtil.d("Click $it") - navController.navigate( - NavigationItems.Detail, - listOf(it.id.toString()) - ) - } - ) - } - item { - Spacer(modifier = Modifier.height(100.dp)) - } - } - } - } - } - } else if (rankListLD.type == LazyType.LOADING) { - LoadingScreen() - } else { - ErrorScreen { - viewModel.fetchRankList() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/search/SearchScreen.kt b/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/search/SearchScreen.kt deleted file mode 100644 index 321086f..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/ui/features/home/search/SearchScreen.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.muedsa.jcytv.ui.features.home.search - -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.Row -import androidx.compose.foundation.layout.Spacer -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.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -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.platform.testTag -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.tv.material3.ButtonDefaults -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.OutlinedIconButton -import com.muedsa.compose.tv.model.ContentModel -import com.muedsa.compose.tv.theme.ImageCardRowCardPadding -import com.muedsa.compose.tv.theme.ScreenPaddingLeft -import com.muedsa.compose.tv.theme.outline -import com.muedsa.compose.tv.useLocalErrorMsgBoxController -import com.muedsa.compose.tv.useLocalNavHostController -import com.muedsa.compose.tv.widget.CardType -import com.muedsa.compose.tv.widget.ImageContentCard -import com.muedsa.compose.tv.widget.ScreenBackgroundType -import com.muedsa.jcytv.ui.GirdLastItemHeight -import com.muedsa.jcytv.ui.VideoPosterSize -import com.muedsa.jcytv.ui.features.home.useLocalHomeScreenBackgroundState -import com.muedsa.jcytv.ui.nav.NavigationItems -import com.muedsa.jcytv.ui.nav.navigate -import com.muedsa.jcytv.viewmodel.SearchViewModel -import com.muedsa.model.LazyType -import com.muedsa.uitl.LogUtil - - -@Composable -fun SearchScreen( - viewModel: SearchViewModel = hiltViewModel() -) { - val backgroundState = useLocalHomeScreenBackgroundState() - val errorMsgBoxState = useLocalErrorMsgBoxController() - val navController = useLocalNavHostController() - - val searchText by viewModel.searchTextSF.collectAsState() - val searchAnimeLD by viewModel.searchAnimeLDSF.collectAsState() - - LaunchedEffect(key1 = searchAnimeLD) { - if (searchAnimeLD.type == LazyType.FAILURE) { - errorMsgBoxState.error(searchAnimeLD.error) - } - } - - Column(modifier = Modifier.padding(start = ScreenPaddingLeft)) { - Row( - modifier = Modifier - .fillMaxWidth() - .offset(x = -ScreenPaddingLeft) - .padding(vertical = 30.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - modifier = Modifier - .fillMaxWidth(0.55f) - .background( - color = MaterialTheme.colorScheme.inverseOnSurface, - shape = OutlinedTextFieldDefaults.shape - ), - textStyle = MaterialTheme.typography.bodyLarge, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.outline, - cursorColor = MaterialTheme.colorScheme.onSurface, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - value = searchText, - onValueChange = { - viewModel.searchTextSF.value = it - }, - singleLine = true - ) - Spacer(modifier = Modifier.width(16.dp)) - OutlinedIconButton( - modifier = Modifier.testTag("searchScreen_searchButton"), - onClick = { viewModel.searchAnime(searchText) } - ) { - Icon( - modifier = Modifier.size(ButtonDefaults.IconSize), - imageVector = Icons.Outlined.Search, - contentDescription = "搜索" - ) - } - } - - if (!searchAnimeLD.data.isNullOrEmpty()) { - val animeList = searchAnimeLD.data!! - val gridFocusRequester = remember { FocusRequester() } - - LazyVerticalGrid( - columns = GridCells.Adaptive(VideoPosterSize.width + ImageCardRowCardPadding), - contentPadding = PaddingValues( - top = ImageCardRowCardPadding, - bottom = ImageCardRowCardPadding - ), - modifier = Modifier - .focusRequester(gridFocusRequester) - ) { - itemsIndexed( - items = animeList, - key = { _, item -> item.id } - ) { _, item -> - ImageContentCard( - modifier = Modifier.padding(end = ImageCardRowCardPadding), - url = item.imageUrl, - imageSize = VideoPosterSize, - type = CardType.STANDARD, - model = ContentModel( - title = item.title, - subtitle = item.subTitle - ), - onItemFocus = { - backgroundState.url = item.imageUrl - backgroundState.type = ScreenBackgroundType.BLUR - }, - onItemClick = { - LogUtil.fb("Click $item") - navController.navigate( - NavigationItems.Detail, - listOf(item.id.toString()) - ) - } - ) - } - - // 最后一行占位 - item { - Spacer(modifier = Modifier.height(GirdLastItemHeight)) - } - } - } - } -} - diff --git a/app/src/main/kotlin/com/muedsa/jcytv/util/JcyConst.kt b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyConst.kt new file mode 100644 index 0000000..eac4e79 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyConst.kt @@ -0,0 +1,18 @@ +package com.muedsa.jcytv.util + +object JcyConst { + + private const val BASE_PATH = "https://www.9ciyuan.com" // 目前发现www子域并不会弹验证码, 仅使用主域名会出现旋转图片验证码 + + const val HOME_URL = "$BASE_PATH/" + + const val SEARCH_URL = "$BASE_PATH/index.php/vod/search.html?wd=" + + const val RANK_URL = "$BASE_PATH/index.php/label/ranking.html" + + const val CATALOG_URL = "$BASE_PATH/index.php/vod/show{query}.html" + + const val DETAIL_URL = "$BASE_PATH/index.php/vod/detail/id/{id}.html" + + const val CHROME_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlParserTool.kt b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlParserTool.kt new file mode 100644 index 0000000..32d43ee --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlParserTool.kt @@ -0,0 +1,109 @@ +package com.muedsa.jcytv.util + +import com.muedsa.jcytv.model.JcyRankList +import com.muedsa.jcytv.model.JcyRankVideoInfo +import com.muedsa.jcytv.model.JcySimpleVideoInfo +import com.muedsa.jcytv.model.JcyVideoDetail +import com.muedsa.jcytv.model.JcyVideoRow +import org.jsoup.nodes.Element + +object JcyHtmlParserTool { + + fun parseHomVideoRows(body: Element): List { + val rows = mutableListOf() + body.select(".vod-list").forEach { + rows.add(parseVideoRow(it)) + } + body.select(".vod-list-tv").forEach { + rows.add(parseTvVideoRow(it)) + } + return rows + } + + fun parseVideoRow(rowEl: Element): JcyVideoRow { + val title = rowEl.selectFirst("h2")!!.text() + val list = rowEl.select("ul li") + .filter { it -> it.selectFirst(".pic") != null } + .map { + val imgDiv = it.selectFirst(".pic")!! + val nameDiv = it.selectFirst(".name")!! + val a = nameDiv.selectFirst("h3 a")!! + JcySimpleVideoInfo( + title = a.text(), + subTitle = nameDiv.selectFirst("p")!!.text(), + detailPagePath = a.attr("href"), + imageUrl = imgDiv.selectFirst(".img-wrapper")!!.absUrl("data-original") + ) + } + return JcyVideoRow(title = title, list = list) + } + + private fun parseTvVideoRow(rowEl: Element): JcyVideoRow { + val title = rowEl.selectFirst("h2 a")!!.text() + val list = rowEl.select("ul li").map { + if (it.hasClass("ranking-item")) { + val imgDiv = it.selectFirst(".ranking-item-left")!! + val nameDiv = it.selectFirst(".ranking-item-info")!! + JcySimpleVideoInfo( + title = nameDiv.selectFirst("h4")!!.text(), + subTitle = nameDiv.selectFirst("p")!!.text(), + detailPagePath = it.selectFirst("a")!!.attr("href"), + imageUrl = imgDiv.selectFirst(".img-wrapper")!!.absUrl("data-original") + ) + } else { + val imgDiv = it.selectFirst(".pic")!! + val nameDiv = it.selectFirst(".name")!! + val a = nameDiv.selectFirst("h3 a")!! + JcySimpleVideoInfo( + title = a.text(), + subTitle = nameDiv.selectFirst("p")!!.text(), + detailPagePath = a.attr("href"), + imageUrl = imgDiv.selectFirst(".img-wrapper")!!.absUrl("data-original") + ) + } + } + return JcyVideoRow(title = title, list = list) + } + + fun parseRankList(body: Element): List { + return body.select(".index-ranking").map { + val title = it.selectFirst("h2")!!.text() + val list = it.parent()!!.select(".ranking-list").select("li").map { li -> + val infoDiv = li.selectFirst(".ranking-item-info")!! + JcyRankVideoInfo( + title = infoDiv.selectFirst("h4")!!.text(), + subTitle = infoDiv.selectFirst("p")!!.text(), + detailPagePath = li.selectFirst("a")!!.attr("href"), + imageUrl = li.selectFirst(".img-wrapper")!!.absUrl("img-wrapper"), + hotNum = li.selectFirst(".ranking-item-hits")!!.text().toInt(), + index = li.selectFirst(".ranking-item-num")!!.text().toInt() + ) + } + JcyRankList(title = title, list = list) + } + } + + fun parseVideoDetail(body: Element): JcyVideoDetail { + val aEl = body.selectFirst(".vod-info .info h3 a")!! + val playListTabRefList = body.select(".playlist-tab ul li").map { + it.attr("data-target") to it.ownText() + } + val playList = playListTabRefList.map { + it.second to body.select("${it.first} li a").map { a -> + a.text() to a.attr("href") + } + } + return JcyVideoDetail( + detailPagePath = aEl.attr("href"), + title = aEl.text(), + status = body.selectFirst(".vod-info .info > p > span:contains(状态)")!!.text() + .replace("状态:", "") + .trim(), + description = body.selectFirst(".vod-info .info .text")!!.text() + .replace("简介:", "") + .trim(), + imageUrl = body.selectFirst(".vod-info .pic img")!!.absUrl("data-original"), + playList = playList + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlTool.kt b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlTool.kt deleted file mode 100644 index aef818b..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/util/JcyHtmlTool.kt +++ /dev/null @@ -1,274 +0,0 @@ -package com.muedsa.jcytv.util - -import com.google.common.net.HttpHeaders -import com.muedsa.jcytv.model.JcyRankVideoInfo -import com.muedsa.jcytv.model.JcyRawPlaySource -import com.muedsa.jcytv.model.JcySimpleVideoInfo -import com.muedsa.jcytv.model.JcyVideoDetail -import com.muedsa.uitl.decodeBase64 -import com.muedsa.uitl.decryptAES128CBCPKCS7 -import com.muedsa.uitl.encryptRC4 -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import java.net.URI -import java.net.URLDecoder -import java.nio.charset.StandardCharsets - -object JcyHtmlTool { - - const val CHROME_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" - - const val MAIN_SITE_URL = "https://www.9ciyuan.com/" - - const val SEARCH_URL = "https://www.9ciyuan.com/index.php/vod/search.html?wd=" - - const val RANK_URL = "https://www.9ciyuan.com/index.php/label/ranking.html" - - const val CATALOG_URL = "https://www.9ciyuan.com/index.php/vod/show{query}.html" - - const val DETAIL_URL = "https://www.9ciyuan.com/index.php/vod/detail/id/{id}.html" - - val DECRYPT_DEFAULT: (String) -> String = { key: String -> key } - - val DECRYPT_NOT_SUPPORT: (String) -> String = - { _: String -> throw IllegalStateException("不支持的播放源") } - - private val SILISILI_ENCRYPTED_URL_REGEX = Regex("\"url\":\"([A-Za-z0-9+/=\\\\]*?)\"") - private val SILISILI_UID_REGEX = Regex("\"uid\":\"([A-Za-z0-9+/=\\\\]*?)\"") - - val DECRYPT_SILISILI: (String) -> String = { key: String -> - val doc: Document = - Jsoup.connect("https://play.silisili.top/player/ec.php?code=ttnb&if=1&url=$key") - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val bodyHtml = doc.body().html() - val urlMatchResult = SILISILI_ENCRYPTED_URL_REGEX.find(bodyHtml) - val uidMatchResult = SILISILI_UID_REGEX.find(bodyHtml) - val encryptedUrl = urlMatchResult!!.groupValues[1].replace("\\/", "/") - val uid = uidMatchResult!!.groupValues[1] - encryptedUrl.decodeBase64().decryptAES128CBCPKCS7("2890${uid}tB959C", "2F131BE91247866E") - .toString(Charsets.UTF_8) - } - - private val DILIDILI_ENCRYPTED_URL_REGEX = Regex("\"url\": \"([A-Za-z0-9+/=\\\\]*?)\"") - - val DECRYPT_DILIDILI: (String) -> String = { key: String -> - val doc: Document = Jsoup.connect("https://play.dilidili.ink/player/analysis.php?v=$key") - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val bodyHtml = doc.body().html() - val urlMatchResult = DILIDILI_ENCRYPTED_URL_REGEX.find(bodyHtml) - val encryptedUrl = urlMatchResult!!.groupValues[1].replace("\\/", "/") - URLDecoder.decode( - encryptedUrl.decodeBase64() - .encryptRC4("202205051426239465".toByteArray()) - .decodeToString(), - StandardCharsets.UTF_8.name() - ) - } - - val DECRYPT_LIBILIBI: (String) -> String = { key: String -> - if (key.endsWith(".m3u8", true) - || key.endsWith(".mp4", true) - || key.endsWith(".flv", true) - ) key - else TODO("NOT_SUPPORT") - } - - val PLAYER_SITE_MAP: Map String> = mapOf( - "NBY" to DECRYPT_DILIDILI, // ✅囧次元N - "ttnb" to DECRYPT_DILIDILI, // https://v.dilidili.ink/player/?url= - "lzm3u8" to DECRYPT_DILIDILI, // ✅囧次元Z - "snm3u8" to DECRYPT_DILIDILI, // ✅囧次元O - "cycp" to DECRYPT_DEFAULT, // ? - "ffm3u8" to DECRYPT_DILIDILI, // ✅囧次元A - "SLNB" to DECRYPT_DEFAULT, // ? - "dplayer" to DECRYPT_DEFAULT, // dplayer - "videojs" to DECRYPT_NOT_SUPPORT, // 不支持videojs - "iva" to DECRYPT_DEFAULT, - "iframe" to DECRYPT_NOT_SUPPORT, // 不支持iframe - "link" to DECRYPT_NOT_SUPPORT, // 不支持link - "swf" to DECRYPT_NOT_SUPPORT, // 不支持swf - "flv" to DECRYPT_DEFAULT, - "ACG" to DECRYPT_SILISILI, // https://play.silisili.top/player/ec.php?code=ttnb&if=1&url= - "languang" to DECRYPT_DEFAULT, // TODO APP线路 https://player.123tv.icu/player/ec.php?code=yunq&if=1&url= - ) - - fun getAbsoluteUrl(path: String): String { - return if (path.startsWith("http://") || path.startsWith("https://")) { - path - } else { - URI.create(MAIN_SITE_URL).resolve(path).toString() - } - } - - fun getHomeVideoRows(): List>> { - val doc: Document = Jsoup.connect(MAIN_SITE_URL) - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val body = doc.body() - val rows = mutableListOf>>() - body.select(".vod-list").forEach { - rows.add(getRowInfo(it)) - } - body.select(".vod-list-tv").forEach { - rows.add(getTvRowInfo(it)) - } - return rows - } - - private fun getRowInfo(rowEl: Element): Pair> { - return rowEl.selectFirst("h2")!!.text() to rowEl.select("ul li") - .filter { it -> it.selectFirst(".pic") != null } - .map { - val imgDiv = it.selectFirst(".pic")!! - val nameDiv = it.selectFirst(".name")!! - val a = nameDiv.selectFirst("h3 a")!! - JcySimpleVideoInfo( - title = a.text(), - subTitle = nameDiv.selectFirst("p")!!.text(), - detailPagePath = a.attr("href"), - imageUrl = getAbsoluteUrl( - imgDiv.selectFirst(".img-wrapper")!!.attr("data-original") - ) - ) - } - } - - private fun getTvRowInfo(rowEl: Element): Pair> { - return rowEl.selectFirst("h2 a")!!.text() to rowEl.select("ul li").map { - if (it.hasClass("ranking-item")) { - val imgDiv = it.selectFirst(".ranking-item-left")!! - val nameDiv = it.selectFirst(".ranking-item-info")!! - JcySimpleVideoInfo( - title = nameDiv.selectFirst("h4")!!.text(), - subTitle = nameDiv.selectFirst("p")!!.text(), - detailPagePath = it.selectFirst("a")!!.attr("href"), - imageUrl = getAbsoluteUrl( - imgDiv.selectFirst(".img-wrapper")!!.attr("data-original") - ) - ) - } else { - val imgDiv = it.selectFirst(".pic")!! - val nameDiv = it.selectFirst(".name")!! - val a = nameDiv.selectFirst("h3 a")!! - JcySimpleVideoInfo( - title = a.text(), - subTitle = nameDiv.selectFirst("p")!!.text(), - detailPagePath = a.attr("href"), - imageUrl = getAbsoluteUrl( - imgDiv.selectFirst(".img-wrapper")!!.attr("data-original") - ) - ) - } - } - } - - fun searchVideo(query: String): List { - val doc: Document = Jsoup.connect("$SEARCH_URL${query}") - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val body = doc.body() - return getRowInfo(body.selectFirst(".vod-list")!!).second - } - - fun rankList(): List>> { - val doc: Document = Jsoup.connect(RANK_URL) - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val body = doc.body() - return body.select(".index-ranking").map { - it.selectFirst("h2")!!.text() to - it.parent()!!.select(".ranking-list").select("li").map { li -> - val infoDiv = li.selectFirst(".ranking-item-info")!! - JcyRankVideoInfo( - title = infoDiv.selectFirst("h4")!!.text(), - subTitle = infoDiv.selectFirst("p")!!.text(), - detailPagePath = li.selectFirst("a")!!.attr("href"), - imageUrl = getAbsoluteUrl( - li.selectFirst(".img-wrapper")!!.attr("img-wrapper") - ), - hotNum = li.selectFirst(".ranking-item-hits")!!.text().toInt(), - index = li.selectFirst(".ranking-item-num")!!.text().toInt() - ) - } - } - } - - fun catalog(queryMap: Map): List { - val query = queryMap.toSortedMap().map { - "/${it.key}/${it.value}" - }.joinToString("") - val doc: Document = Jsoup.connect(CATALOG_URL.replace("{query}", query)) - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val body = doc.body() - return getRowInfo(body.selectFirst(".vod-list")!!).second - } - - fun getVideoDetailById(id: Long): JcyVideoDetail { - return getVideoDetailByUrl(DETAIL_URL.replace("{id}", id.toString())) - } - - fun getVideoDetailByUrl(url: String): JcyVideoDetail { - val doc: Document = Jsoup.connect(url) - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val body = doc.body() - val aEl = body.selectFirst(".vod-info .info h3 a")!! - val playListTabRefList = body.select(".playlist-tab ul li").map { - it.attr("data-target") to it.ownText() - } - val playList = playListTabRefList.map { - it.second to body.select("${it.first} li a").map { a -> - a.text() to a.attr("href") - } - } - return JcyVideoDetail( - detailPagePath = aEl.attr("href"), - title = aEl.text(), - status = body.selectFirst(".vod-info .info > p > span:contains(状态)")!!.text() - .replace("状态:", "") - .trim(), - description = body.selectFirst(".vod-info .info .text")!!.text() - .replace("简介:", "") - .trim(), - imageUrl = getAbsoluteUrl( - body.selectFirst(".vod-info .pic img")!!.attr("data-original") - ), - playList = playList - ) - } - - private val RAW_PLAY_SOURCE_URL_REGEX = Regex("\"url\":\"([A-Za-z0-9%]*?)\"") - private val RAW_PLAY_SOURCE_URL_NEXT_REGEX = Regex("\"url_next\":\"([A-Za-z0-9%]*?)\"") - private val RAW_PLAY_SOURCE_FROM_REGEX = Regex("\"from\":\"([A-Za-z0-9]*?)\"") - - fun getRawPlaySource(url: String): JcyRawPlaySource { - val doc: Document = Jsoup.connect(url) - .header(HttpHeaders.REFERER, MAIN_SITE_URL) - .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) - .get() - val bodyHtml = doc.body().html() - - return JcyRawPlaySource( - url = RAW_PLAY_SOURCE_URL_REGEX.find(bodyHtml)!!.groupValues[1], - urlNext = RAW_PLAY_SOURCE_URL_NEXT_REGEX.find(bodyHtml)!!.groupValues[1], - from = RAW_PLAY_SOURCE_FROM_REGEX.find(bodyHtml)!!.groupValues[1] - ) - } - - fun getRealPlayUrl(rawPlaySource: JcyRawPlaySource): String { - val decodedUrl = URLDecoder.decode(rawPlaySource.url, StandardCharsets.UTF_8.name()) - val decrypt = PLAYER_SITE_MAP[rawPlaySource.from] - return decrypt?.invoke(decodedUrl) ?: decodedUrl - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/util/JcyPlaySourceTool.kt b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyPlaySourceTool.kt new file mode 100644 index 0000000..c89184d --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyPlaySourceTool.kt @@ -0,0 +1,117 @@ +package com.muedsa.jcytv.util + +import com.google.common.net.HttpHeaders +import com.muedsa.jcytv.model.JcyRawPlaySource +import com.muedsa.uitl.decodeBase64 +import com.muedsa.uitl.decryptAES128CBCPKCS7 +import com.muedsa.uitl.encryptRC4 +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +object JcyPlaySourceTool { + + const val CHROME_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" + + val DECRYPT_DEFAULT: (String) -> String = { key: String -> key } + + val DECRYPT_NOT_SUPPORT: (String) -> String = + { _: String -> throw IllegalStateException("不支持的播放源") } + + private val SILISILI_ENCRYPTED_URL_REGEX = Regex("\"url\":\"([A-Za-z0-9+/=\\\\]*?)\"") + private val SILISILI_UID_REGEX = Regex("\"uid\":\"([A-Za-z0-9+/=\\\\]*?)\"") + + val DECRYPT_SILISILI: (String) -> String = { key: String -> + val doc: Document = + Jsoup.connect("https://play.silisili.top/player/ec.php?code=ttnb&if=1&url=$key") + .header(HttpHeaders.REFERER, JcyConst.HOME_URL) + .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) + .get() + val bodyHtml = doc.body().html() + val urlMatchResult = SILISILI_ENCRYPTED_URL_REGEX.find(bodyHtml) + val uidMatchResult = SILISILI_UID_REGEX.find(bodyHtml) + val encryptedUrl = urlMatchResult!!.groupValues[1].replace("\\/", "/") + val uid = uidMatchResult!!.groupValues[1] + encryptedUrl.decodeBase64().decryptAES128CBCPKCS7("2890${uid}tB959C", "2F131BE91247866E") + .toString(Charsets.UTF_8) + } + + private val DILIDILI_ENCRYPTED_URL_REGEX = Regex("\"url\": \"([A-Za-z0-9+/=\\\\]*?)\"") + + val DECRYPT_DILIDILI: (String) -> String = { key: String -> + val doc: Document = Jsoup.connect("https://play.dilidili.ink/player/analysis.php?v=$key") + .header(HttpHeaders.REFERER, JcyConst.HOME_URL) + .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) + .get() + val bodyHtml = doc.body().html() + val urlMatchResult = DILIDILI_ENCRYPTED_URL_REGEX.find(bodyHtml) + val encryptedUrl = urlMatchResult!!.groupValues[1].replace("\\/", "/") + URLDecoder.decode( + encryptedUrl.decodeBase64() + .encryptRC4("202205051426239465".toByteArray()) + .decodeToString(), + StandardCharsets.UTF_8.name() + ) + } + + val DECRYPT_LIBILIBI: (String) -> String = { key: String -> + if (key.endsWith(".m3u8", true) + || key.endsWith(".mp4", true) + || key.endsWith(".flv", true) + ) key + else TODO("NOT_SUPPORT") + } + + val PLAYER_SITE_MAP: Map String> = mapOf( + "NBY" to DECRYPT_DILIDILI, // ✅囧次元N + "ttnb" to DECRYPT_DILIDILI, // https://v.dilidili.ink/player/?url= + "lzm3u8" to DECRYPT_DILIDILI, // ✅囧次元Z + "snm3u8" to DECRYPT_DILIDILI, // ✅囧次元O + "cycp" to DECRYPT_DEFAULT, // ? + "ffm3u8" to DECRYPT_DILIDILI, // ✅囧次元A + "SLNB" to DECRYPT_DEFAULT, // ? + "dplayer" to DECRYPT_DEFAULT, // dplayer + "videojs" to DECRYPT_NOT_SUPPORT, // 不支持videojs + "iva" to DECRYPT_DEFAULT, + "iframe" to DECRYPT_NOT_SUPPORT, // 不支持iframe + "link" to DECRYPT_NOT_SUPPORT, // 不支持link + "swf" to DECRYPT_NOT_SUPPORT, // 不支持swf + "flv" to DECRYPT_DEFAULT, + "ACG" to DECRYPT_SILISILI, // https://play.silisili.top/player/ec.php?code=ttnb&if=1&url= + "languang" to DECRYPT_DEFAULT, // TODO APP线路 https://player.123tv.icu/player/ec.php?code=yunq&if=1&url= + ) + + fun getAbsoluteUrl(path: String): String { + return if (path.startsWith("http://") || path.startsWith("https://")) { + path + } else { + URI.create(JcyConst.HOME_URL).resolve(path).toString() + } + } + + private val RAW_PLAY_SOURCE_URL_REGEX = Regex("\"url\":\"([A-Za-z0-9%]*?)\"") + private val RAW_PLAY_SOURCE_URL_NEXT_REGEX = Regex("\"url_next\":\"([A-Za-z0-9%]*?)\"") + private val RAW_PLAY_SOURCE_FROM_REGEX = Regex("\"from\":\"([A-Za-z0-9]*?)\"") + + fun getRawPlaySource(url: String): JcyRawPlaySource { + val doc: Document = Jsoup.connect(url) + .header(HttpHeaders.REFERER, JcyConst.HOME_URL) + .header(HttpHeaders.USER_AGENT, CHROME_USER_AGENT) + .get() + val bodyHtml = doc.body().html() + + return JcyRawPlaySource( + url = RAW_PLAY_SOURCE_URL_REGEX.find(bodyHtml)!!.groupValues[1], + urlNext = RAW_PLAY_SOURCE_URL_NEXT_REGEX.find(bodyHtml)!!.groupValues[1], + from = RAW_PLAY_SOURCE_FROM_REGEX.find(bodyHtml)!!.groupValues[1] + ) + } + + fun getRealPlayUrl(rawPlaySource: JcyRawPlaySource): String { + val decodedUrl = URLDecoder.decode(rawPlaySource.url, StandardCharsets.UTF_8.name()) + val decrypt = PLAYER_SITE_MAP[rawPlaySource.from] + return decrypt?.invoke(decodedUrl) ?: decodedUrl + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/util/JcyRotateCaptchaTool.kt b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyRotateCaptchaTool.kt new file mode 100644 index 0000000..754e735 --- /dev/null +++ b/app/src/main/kotlin/com/muedsa/jcytv/util/JcyRotateCaptchaTool.kt @@ -0,0 +1,67 @@ +package com.muedsa.jcytv.util + +import com.google.common.net.HttpHeaders +import com.muedsa.uitl.encodeBase64 +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.nodes.Element +import java.net.HttpCookie + +object JcyRotateCaptchaTool { + + const val CAPTCHA_CHECK_URL = "https://9ciyuan.com" + + const val HTML_KEYWORD = "/_guard/html.js?js=rotate_html" + + private const val CAPTCHA_IMAGE_URL = "https://9ciyuan.com/_guard/rotate.jpg?t=" + + const val COOKIE_GUARD = "guard" + const val COOKIE_GUARD_RESULT = "guardret" + const val COOKIE_GUARD_OK = "guardok" + + private const val MAGIC_STRING = "qweabcPTNo2n3Ev5" + + private val client: OkHttpClient = OkHttpClient.Builder().build() + + fun checkIfNeedValidateCaptcha(head: Element): Boolean { + return head.html().contains(HTML_KEYWORD) + } + + fun buildCaptchaImageUrl(): String { + val t = System.currentTimeMillis() + return "$CAPTCHA_IMAGE_URL$t" + } + + fun getGuardRet(deg: Float): String { + val degCharArr = deg.toString().toCharArray() + var output = "" + for ((index, c) in degCharArr.withIndex()) { + val charCode = c.code xor MAGIC_STRING[index].code + output += charCode.toChar() + } + return output.toByteArray(Charsets.UTF_8).encodeBase64() + } + + fun getGuardOk(guard: String, deg: Float): String? { + val req = Request.Builder() + .url(CAPTCHA_CHECK_URL) + .header(HttpHeaders.REFERER, CAPTCHA_CHECK_URL) + .header(HttpHeaders.USER_AGENT, JcyConst.CHROME_USER_AGENT) + .header(HttpHeaders.COOKIE, "$COOKIE_GUARD=$guard; $COOKIE_GUARD_RESULT=${getGuardRet(deg)}") + .get() + .build() + val resp = client.newCall(req).execute() + return getSetCookieValueFromHeaders(resp.headers, COOKIE_GUARD_OK) + } + + fun getSetCookieValueFromHeaders(headers: Headers, name: String): String? { + val setCookieList = headers.values(HttpHeaders.SET_COOKIE) + return setCookieList.find { it.startsWith("$name=") }?.also { + val cookieList = HttpCookie.parse(it) + val cookie = cookieList.firstOrNull { c -> c.name == name } + return cookie?.value + } + } +} + diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/HomePageViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/HomePageViewModel.kt deleted file mode 100644 index 19d3e2a..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/HomePageViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.muedsa.jcytv.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.muedsa.jcytv.model.JcySimpleVideoInfo -import com.muedsa.jcytv.util.JcyHtmlTool -import com.muedsa.model.LazyData -import com.muedsa.uitl.LogUtil -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@HiltViewModel -class HomePageViewModel @Inject constructor() : ViewModel() { - - private val _homeRowsSF = MutableStateFlow(LazyData.init>>>()) - val homeRowsSF: StateFlow>>>> = _homeRowsSF - - fun refreshHomeData() { - viewModelScope.launch { - _homeRowsSF.value = withContext(Dispatchers.IO) { - fetchHomeRows() - } - } - } - - private suspend fun fetchHomeRows(): LazyData>>> { - return try { - LazyData.success(JcyHtmlTool.getHomeVideoRows()) - } catch (t: Throwable) { - LogUtil.fb(t) - LazyData.fail(t) - } - } - - init { - refreshHomeData() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/RankViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/RankViewModel.kt deleted file mode 100644 index 0cdd04d..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/RankViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.muedsa.jcytv.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.muedsa.jcytv.model.JcyRankVideoInfo -import com.muedsa.jcytv.util.JcyHtmlTool -import com.muedsa.model.LazyData -import com.muedsa.uitl.LogUtil -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@HiltViewModel -class RankViewModel @Inject constructor() : ViewModel() { - - private val _rankListLDSF = MutableStateFlow(LazyData.init>>>()) - val rankListLDSF: StateFlow>>>> = _rankListLDSF - - fun fetchRankList() { - viewModelScope.launch { - _rankListLDSF.value = LazyData.init() - _rankListLDSF.value = withContext(Dispatchers.IO) { - rankList() - } - } - } - - private suspend fun rankList(): LazyData>>> { - return try { - LazyData.success(JcyHtmlTool.rankList()) - } catch (t: Throwable) { - LogUtil.fb(t) - LazyData.fail(t) - } - } - - init { - fetchRankList() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/SearchViewModel.kt b/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/SearchViewModel.kt deleted file mode 100644 index 98fadc1..0000000 --- a/app/src/main/kotlin/com/muedsa/jcytv/viewmodel/SearchViewModel.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.muedsa.jcytv.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.muedsa.jcytv.model.JcySimpleVideoInfo -import com.muedsa.jcytv.util.JcyHtmlTool -import com.muedsa.model.LazyData -import com.muedsa.uitl.LogUtil -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@HiltViewModel -class SearchViewModel @Inject constructor() : ViewModel() { - - val searchTextSF = MutableStateFlow("") - private val _searchAnimeLDSF = MutableStateFlow(LazyData.success>(emptyList())) - val searchAnimeLDSF: StateFlow>> = _searchAnimeLDSF - - fun searchAnime(query: String) { - if (query.isNotBlank()) { - viewModelScope.launch { - _searchAnimeLDSF.value = LazyData.init() - _searchAnimeLDSF.value = withContext(Dispatchers.IO) { - fetchSearch(query) - } - } - } - } - - private suspend fun fetchSearch( - query: String - ): LazyData> { - return try { - LazyData.success(JcyHtmlTool.searchVideo(query)) - } catch (t: Throwable) { - LogUtil.fb(t) - LazyData.fail(t) - } - } -} \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 77c66c5..b0ff6de 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -4,6 +4,7 @@