├── .gitattributes ├── .github └── workflows │ ├── android_ci.yml │ └── release.yml ├── .gitignore ├── CNAME ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro ├── schemas │ ├── com.lanlinju.animius.data.local.database.AnimeDatabase │ │ └── 3.json │ └── com.sakura.anime.data.local.database.AnimeDatabase │ │ ├── 1.json │ │ ├── 2.json │ │ └── 3.json └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── lanlinju │ │ └── animius │ │ ├── ExampleInstrumentedTest.kt │ │ ├── UpdateVersionTest.kt │ │ ├── dandanplay │ │ └── DandanplayDanmakuProviderTest.kt │ │ └── database_test │ │ ├── DownloadDaoTest.kt │ │ ├── DownloadDetailTest.kt │ │ ├── EpisodeDaoTest.kt │ │ ├── FavouriteDaoTest.kt │ │ └── HistoryDaoTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── lanlinju │ │ │ └── animius │ │ │ ├── MainActivity.kt │ │ │ ├── application │ │ │ └── AnimeApplication.kt │ │ │ ├── data │ │ │ ├── local │ │ │ │ ├── dao │ │ │ │ │ ├── DownloadDao.kt │ │ │ │ │ ├── DownloadDetailDao.kt │ │ │ │ │ ├── EpisodeDao.kt │ │ │ │ │ ├── FavouriteDao.kt │ │ │ │ │ └── HistoryDao.kt │ │ │ │ ├── database │ │ │ │ │ └── AnimeDatabase.kt │ │ │ │ ├── entity │ │ │ │ │ ├── DownloadDetailEntity.kt │ │ │ │ │ ├── DownloadEntity.kt │ │ │ │ │ ├── EpisodeEntity.kt │ │ │ │ │ ├── FavouriteEntity.kt │ │ │ │ │ └── HistoryEntity.kt │ │ │ │ └── relation │ │ │ │ │ ├── DownloadWithDownloadDetails.kt │ │ │ │ │ └── HistoryWithEpisodes.kt │ │ │ ├── remote │ │ │ │ ├── api │ │ │ │ │ ├── AnimeApi.kt │ │ │ │ │ └── AnimeApiImpl.kt │ │ │ │ ├── dandanplay │ │ │ │ │ ├── DandanplayClient.kt │ │ │ │ │ ├── DandanplayDanmakuProvider.kt │ │ │ │ │ └── dto │ │ │ │ │ │ ├── DandanplayDanmaku.kt │ │ │ │ │ │ └── DandanplaySearchEpisodeResponse.kt │ │ │ │ ├── dto │ │ │ │ │ ├── AnimeBean.kt │ │ │ │ │ ├── AnimeDetailBean.kt │ │ │ │ │ ├── EpisodeBean.kt │ │ │ │ │ ├── HomeBean.kt │ │ │ │ │ └── VideoBean.kt │ │ │ │ └── parse │ │ │ │ │ ├── AgedmSource.kt │ │ │ │ │ ├── AnfunsSource.kt │ │ │ │ │ ├── AnimeSource.kt │ │ │ │ │ ├── CycanimeSource.kt │ │ │ │ │ ├── GirigiriSource.kt │ │ │ │ │ ├── GogoanimeSource.kt │ │ │ │ │ ├── MxdmSource.kt │ │ │ │ │ ├── NyafunSource.kt │ │ │ │ │ ├── SilisiliSource.kt │ │ │ │ │ ├── YhdmSource.kt │ │ │ │ │ └── util │ │ │ │ │ └── WebViewUtil.kt │ │ │ └── repository │ │ │ │ ├── AnimeRepositoryImpl.kt │ │ │ │ ├── DanmakuRepositoryImpl.kt │ │ │ │ ├── RoomRepositoryImpl.kt │ │ │ │ └── paging │ │ │ │ └── SearchPagingSource.kt │ │ │ ├── di │ │ │ ├── ApiModule.kt │ │ │ └── AppModule.kt │ │ │ ├── domain │ │ │ ├── model │ │ │ │ ├── Anime.kt │ │ │ │ ├── AnimeDetail.kt │ │ │ │ ├── Download.kt │ │ │ │ ├── DownloadDetail.kt │ │ │ │ ├── Episode.kt │ │ │ │ ├── Favourite.kt │ │ │ │ ├── History.kt │ │ │ │ ├── Home.kt │ │ │ │ ├── Video.kt │ │ │ │ └── WebVideo.kt │ │ │ ├── repository │ │ │ │ ├── AnimeRepository.kt │ │ │ │ ├── DanmakuRepository.kt │ │ │ │ └── RoomRepository.kt │ │ │ └── usecase │ │ │ │ └── GetAnimeDetailUseCase.kt │ │ │ ├── presentation │ │ │ ├── component │ │ │ │ ├── BackTopAppBar.kt │ │ │ │ ├── LoadingIndicator.kt │ │ │ │ ├── MediaSmall.kt │ │ │ │ ├── NavigationBar.kt │ │ │ │ ├── PaginationStateHandler.kt │ │ │ │ ├── PopupMenuListItem.kt │ │ │ │ ├── ScrollableText.kt │ │ │ │ ├── SourceBadge.kt │ │ │ │ ├── StateHandler.kt │ │ │ │ ├── TranslucentStatusBarLayout.kt │ │ │ │ └── WarningMessage.kt │ │ │ ├── navigation │ │ │ │ ├── AnimeNavHost.kt │ │ │ │ └── Screen.kt │ │ │ ├── screen │ │ │ │ ├── crash │ │ │ │ │ ├── CrashActivity.kt │ │ │ │ │ └── CrashScreen.kt │ │ │ │ ├── detail │ │ │ │ │ ├── AnimeDetailScreen.kt │ │ │ │ │ └── AnimeDetailViewModel.kt │ │ │ │ ├── download │ │ │ │ │ ├── DownloadScreen.kt │ │ │ │ │ └── DownloadViewModel.kt │ │ │ │ ├── downloaddetail │ │ │ │ │ ├── DownloadDetailScreen.kt │ │ │ │ │ └── DownloadDetailViewModel.kt │ │ │ │ ├── favourite │ │ │ │ │ ├── FavouriteScreen.kt │ │ │ │ │ └── FavouriteViewModel.kt │ │ │ │ ├── history │ │ │ │ │ ├── HistoryScreen.kt │ │ │ │ │ └── HistoryViewModel.kt │ │ │ │ ├── home │ │ │ │ │ ├── HomeScreen.kt │ │ │ │ │ └── HomeViewModel.kt │ │ │ │ ├── main │ │ │ │ │ ├── MainScreen.kt │ │ │ │ │ └── MainViewModel.kt │ │ │ │ ├── search │ │ │ │ │ ├── SearchScreen.kt │ │ │ │ │ └── SearchViewModel.kt │ │ │ │ ├── settings │ │ │ │ │ ├── AppearanceScreen.kt │ │ │ │ │ └── DanmakuSettingsScreen.kt │ │ │ │ ├── videoplayer │ │ │ │ │ ├── VideoPlayerScreen.kt │ │ │ │ │ └── VideoPlayerViewModel.kt │ │ │ │ └── week │ │ │ │ │ ├── WeekScreen.kt │ │ │ │ │ └── WeekViewModel.kt │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── util │ │ │ ├── Const.kt │ │ │ ├── DownloadManager.kt │ │ │ ├── HttpClientExt.kt │ │ │ ├── ModifierExt.kt │ │ │ ├── Preferences.kt │ │ │ ├── Resource.kt │ │ │ ├── Result.kt │ │ │ ├── SettingsPreferences.kt │ │ │ ├── SourceHolder.kt │ │ │ ├── ThemeUtil.kt │ │ │ └── Util.kt │ │ │ └── work │ │ │ └── UpdateWorker.kt │ └── res │ │ ├── drawable │ │ ├── background.png │ │ ├── home.xml │ │ ├── ic_brightness.xml │ │ ├── ic_content_paste.xml │ │ ├── ic_domain.xml │ │ ├── ic_github.png │ │ ├── ic_history.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_volume_mute.xml │ │ ├── ic_volume_up.xml │ │ ├── manga.xml │ │ ├── rslash.xml │ │ └── search.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher_foreground.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ └── file_paths.xml │ └── test │ └── java │ └── com │ └── lanlinju │ └── animius │ ├── dandanplay │ ├── DandanplayClientTest.kt │ ├── MoviePatternTest.kt │ └── SignatureGeneratorTest.kt │ ├── parse │ ├── cycanime │ │ ├── CycanimeSourceTest.kt │ │ └── html │ │ │ ├── detail.html │ │ │ ├── home.html │ │ │ ├── player.html │ │ │ └── search.html │ ├── gogoanime │ │ ├── GogoanimeTest.kt │ │ └── html │ │ │ ├── detail.html │ │ │ ├── home.html │ │ │ ├── player.html │ │ │ ├── search.html │ │ │ └── week.html │ └── nyafun │ │ ├── NyafunSourceTest.kt │ │ ├── detail.html │ │ ├── home.html │ │ ├── player.html │ │ └── search.html │ ├── serializer │ └── SerializerTest.kt │ └── source │ └── YhdmSourceTest.kt ├── build.gradle.kts ├── danmaku ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── anime │ │ └── danmaku │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── anime │ │ └── danmaku │ │ ├── api │ │ ├── Danmaku.kt │ │ └── DanmakuSession.kt │ │ └── ui │ │ ├── DanmakuConfig.kt │ │ ├── DanmakuHost.kt │ │ ├── DanmakuHostState.kt │ │ ├── DanmakuTrack.kt │ │ ├── FixedDanmakuTrack.kt │ │ ├── FloatingDanmakuTrack.kt │ │ ├── StyledDanmaku.kt │ │ └── Util.kt │ └── test │ └── java │ └── com │ └── anime │ └── danmaku │ └── TimeBasedDanmakuSessionTest.kt ├── download ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── lanlinju │ └── download │ ├── Download.kt │ ├── Progress.kt │ ├── State.kt │ ├── core │ ├── DownloadConfig.kt │ ├── DownloadParam.kt │ ├── DownloadQueue.kt │ ├── DownloadTask.kt │ ├── Downloader.kt │ ├── Extensions.kt │ ├── M3u8Downloader.kt │ ├── M3u8Parser.kt │ ├── NormalDownloader.kt │ ├── RangeDownloader.kt │ ├── RangeTmpFile.kt │ └── TaskManager.kt │ ├── helper │ ├── Default.kt │ └── Request.kt │ └── utils │ ├── FileUtils.kt │ ├── HttpUtil.kt │ ├── LogUtil.kt │ └── Util.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── index.html ├── logo.png ├── settings.gradle.kts └── video-player ├── .gitignore ├── build.gradle.kts ├── consumer-rules.pro ├── proguard-rules.pro └── src └── main ├── AndroidManifest.xml ├── java └── com │ └── lanlinju │ └── videoplayer │ ├── Utils.kt │ ├── VideoPlayer.kt │ ├── VideoPlayerControl.kt │ ├── VideoPlayerSate.kt │ ├── component │ └── Slider.kt │ └── icons │ ├── ArrowBackIos.kt │ ├── Fullscreen.kt │ ├── FullscreenExit.kt │ ├── Pause.kt │ ├── PlayArrow.kt │ ├── Subtitles.kt │ └── SubtitlesOff.kt └── res └── drawable └── ic_next.xml /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-vendored -------------------------------------------------------------------------------- /.github/workflows/android_ci.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - 'main' 9 | - 'feat-compat-tv' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 30 # 设置超时时间为30分钟 15 | 16 | steps: 17 | # Checkout the code 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | # Set up JDK 17 22 | - name: Set up JDK 17 23 | uses: actions/setup-java@v4 24 | with: 25 | distribution: 'temurin' 26 | java-version: '17' 27 | cache: 'gradle' 28 | 29 | - name: Set up Android SDK 30 | uses: android-actions/setup-android@v3 31 | 32 | - name: Set up Gradle 33 | uses: gradle/actions/setup-gradle@v4 34 | 35 | # Decode and write the signing key from secrets 36 | - name: Decode signing key 37 | run: echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 -di > keystore.jks 38 | 39 | # Build the APK 40 | - name: Build APK 41 | run: | 42 | chmod +x ./gradlew 43 | ./gradlew assembleRelease 44 | env: 45 | KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} 46 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} 47 | KEY_ALIAS: ${{ secrets.KEY_ALIAS }} 48 | 49 | # Sign the APK (Already handled by Gradle) 50 | # Upload APK as artifact 51 | - name: Upload Signed APK 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: pre-release 55 | path: app/build/outputs/apk/release/*.apk -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Release APK 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: [ 'v*' ] 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | # Checkout the code 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | # Set up JDK 17 18 | - name: Set up JDK 17 19 | uses: actions/setup-java@v4 20 | with: 21 | distribution: 'temurin' 22 | java-version: '17' 23 | cache: 'gradle' 24 | 25 | - name: Set up Android SDK 26 | uses: android-actions/setup-android@v3 27 | 28 | - name: Set up Gradle 29 | uses: gradle/actions/setup-gradle@v4 30 | 31 | # Decode and write the signing key from secrets 32 | - name: Decode signing key 33 | run: echo "${{ secrets.SIGNING_KEY_BASE64 }}" | base64 -di > keystore.jks 34 | 35 | # Build the APK 36 | - name: Build APK 37 | run: | 38 | chmod +x ./gradlew 39 | ./gradlew assembleRelease 40 | env: 41 | KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }} 42 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} 43 | KEY_ALIAS: ${{ secrets.KEY_ALIAS }} 44 | 45 | # Sign the APK (Already handled by Gradle) 46 | # Upload APK as artifact 47 | - name: Upload Signed APK 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: app-release 51 | path: app/build/outputs/apk/release/*.apk -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | release/ 12 | /.kotlin 13 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | app.laqoo.eu.org -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LaQoo 2 | 3 | 一个简洁的播放动漫的App,支持下载,弹幕,多数据源等功能 4 | 5 | ## 如何下载安装 6 | 7 | 点击此链接[下载地址](https://app.laqoo.eu.org/) 8 | 9 | ## 来源 10 | 11 | 原始源代码来自于[lanlinju/Animius](https://github.com/lanlinju/Animius)的Animius项目 12 | 13 | ## 相关功能 14 | 15 | - [x] 首页推荐 16 | - [x] 番剧搜索 17 | - [x] 番剧时间表 18 | - [x] 多数据源支持 19 | - [x] 历史记录 20 | - [x] 番剧下载 21 | - [x] 番剧收藏 22 | - [x] 动态主题颜色 23 | - [x] 视频播放器 24 | - [x] 倍速播放 25 | - [x] 外部播放器播放 26 | - [x] 弹幕功能 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | # Please add these rules to your existing keep rules in order to suppress warnings. 23 | # This is generated automatically by the Android Gradle plugin. 24 | -dontwarn org.bouncycastle.jsse.BCSSLParameters 25 | -dontwarn org.bouncycastle.jsse.BCSSLSocket 26 | -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider 27 | -dontwarn org.conscrypt.Conscrypt$Version 28 | -dontwarn org.conscrypt.Conscrypt 29 | -dontwarn org.conscrypt.ConscryptHostnameVerifier 30 | -dontwarn org.openjsse.javax.net.ssl.SSLParameters 31 | -dontwarn org.openjsse.javax.net.ssl.SSLSocket 32 | -dontwarn org.openjsse.net.ssl.OpenJSSE 33 | 34 | # 保留 SourceMode 枚举类及其成员 35 | -keep enum com.lanlinju.animius.util.SourceMode { *; } 36 | 37 | # https://developer.android.com/build/shrink-code?utm_source=android-studio&hl=zh-cn#retracing 38 | # 对堆栈轨迹进行轨迹还原 39 | -keepattributes LineNumberTable,SourceFile 40 | -renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lanlinju/animius/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.sakura.anime", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lanlinju/animius/UpdateVersionTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import com.lanlinju.animius.util.DownloadManager 6 | import kotlinx.coroutines.runBlocking 7 | import org.json.JSONObject 8 | import org.junit.Assert 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | 12 | @RunWith(AndroidJUnit4::class) 13 | class UpdateVersionTest { 14 | val UPDATE_ADDRESS = "https://api.github.com/repos/laqoome/LaQoo/releases/latest" 15 | 16 | @Test 17 | fun checkUpdate(): Unit = runBlocking { 18 | val json = DownloadManager.getHtml(UPDATE_ADDRESS) 19 | val obj = JSONObject(json) 20 | val latestVersionName = obj.getString("name") 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | val curVersionName = appContext.packageManager 23 | .getPackageInfo(appContext.packageName, 0).versionName 24 | val downloadUrl = obj.getJSONArray("assets").getJSONObject(0).getString("browser_download_url") 25 | println(downloadUrl) 26 | println(obj.getString("body")) 27 | Assert.assertEquals(curVersionName, latestVersionName) 28 | } 29 | 30 | @Test 31 | fun getVersionName() { 32 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 33 | val versionName = appContext.packageManager 34 | .getPackageInfo(appContext.packageName, 0).versionName 35 | println("v$versionName") 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lanlinju/animius/dandanplay/DandanplayDanmakuProviderTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.dandanplay 2 | 3 | import com.lanlinju.animius.data.remote.dandanplay.DandanplayDanmakuProviderFactory 4 | import kotlinx.coroutines.runBlocking 5 | import org.junit.Assert.assertNotNull 6 | import org.junit.Test 7 | 8 | class DandanplayDanmakuProviderTest { 9 | private val provider = DandanplayDanmakuProviderFactory().create() 10 | 11 | @Test 12 | fun testFetchTVDanmakuSession() = runBlocking { 13 | 14 | val session = provider.fetch("海贼王", "第11话") 15 | 16 | assertNotNull(session) 17 | } 18 | 19 | @Test 20 | fun testFetchMovieDanmakuSession() = runBlocking { 21 | 22 | val session = provider.fetch("紫罗兰永恒花园 剧场版", "全集") 23 | 24 | assertNotNull(session) 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lanlinju/animius/database_test/DownloadDaoTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.database_test 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import androidx.test.core.app.ApplicationProvider 6 | import androidx.test.ext.junit.runners.AndroidJUnit4 7 | import com.lanlinju.animius.data.local.dao.DownloadDao 8 | import com.lanlinju.animius.data.local.database.AnimeDatabase 9 | import com.lanlinju.animius.data.local.entity.DownloadEntity 10 | import com.lanlinju.animius.util.SourceMode 11 | import kotlinx.coroutines.flow.first 12 | import kotlinx.coroutines.runBlocking 13 | import org.junit.After 14 | import org.junit.Assert.* 15 | import org.junit.Before 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import java.io.IOException 19 | 20 | @RunWith(AndroidJUnit4::class) 21 | class DownloadDaoTest { 22 | private lateinit var downloadDao: DownloadDao 23 | private lateinit var animeDatabase: AnimeDatabase 24 | 25 | private var download1 = 26 | DownloadEntity(1, "海贼王1", "/detailUrl1", "/imgUrl1", SourceMode.Yhdm.name, System.currentTimeMillis()) 27 | private var download2 = 28 | DownloadEntity(2, "海贼王2", "/detailUrl2", "/imgUrl2", SourceMode.Yhdm.name, System.currentTimeMillis()) 29 | 30 | @Before 31 | fun createDb() { 32 | val context: Context = ApplicationProvider.getApplicationContext() 33 | animeDatabase = Room.inMemoryDatabaseBuilder(context, AnimeDatabase::class.java) 34 | .allowMainThreadQueries() 35 | .build() 36 | downloadDao = animeDatabase.downLoadDao() 37 | } 38 | 39 | @After 40 | @Throws(IOException::class) 41 | fun closeDb() { 42 | animeDatabase.close() 43 | } 44 | 45 | @Test 46 | @Throws(Exception::class) 47 | fun daoInsert_insertsHistoryIntoDB() = runBlocking { 48 | addOneDownloadToDb() 49 | val download = downloadDao.getDownload(download1.detailUrl).first() 50 | assertEquals(download.detailUrl, download1.detailUrl) 51 | } 52 | 53 | @Test 54 | fun daoCheckDownload_returnsNotNullFromDB() = runBlocking { 55 | val download = downloadDao.checkDownload(download1.detailUrl).first() 56 | assertNull(download) 57 | } 58 | 59 | @Test 60 | fun daoCheckDownload_returnsNullFromDB() = runBlocking { 61 | addOneDownloadToDb() 62 | val download = downloadDao.checkDownload(download1.detailUrl).first() 63 | assertNotNull(download) 64 | } 65 | 66 | @Test 67 | fun daoDeleteOneDownload_deleteOneDownloadFromDB() = runBlocking { 68 | addOneDownloadToDb() 69 | downloadDao.deleteDownload(download1.detailUrl) 70 | val actual = downloadDao.checkDownload(download1.detailUrl).first() 71 | assertNull(actual) 72 | } 73 | 74 | private suspend fun addOneDownloadToDb() { 75 | downloadDao.insertDownload(download1) 76 | } 77 | 78 | private suspend fun addTwoDownloadsToDb() { 79 | downloadDao.insertDownload(download1) 80 | downloadDao.insertDownload(download2) 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 37 | 38 | 43 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/application/AnimeApplication.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.application 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.hilt.android.HiltAndroidApp 6 | 7 | @HiltAndroidApp 8 | class AnimeApplication : Application() { 9 | 10 | override fun onCreate() { 11 | super.onCreate() 12 | 13 | _instance = this 14 | 15 | } 16 | 17 | companion object { 18 | private lateinit var _instance: Application 19 | 20 | fun getInstance(): Context { 21 | return _instance 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/dao/DownloadDao.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import androidx.room.Update 9 | import com.lanlinju.animius.data.local.entity.DownloadEntity 10 | import com.lanlinju.animius.data.local.relation.DownloadWithDownloadDetails 11 | import com.lanlinju.animius.util.DOWNLOAD_TABLE 12 | import kotlinx.coroutines.flow.Flow 13 | 14 | @Dao 15 | interface DownloadDao { 16 | 17 | @Transaction 18 | @Query("SELECT * FROM $DOWNLOAD_TABLE ORDER BY created_at DESC") 19 | fun getDownloads(): Flow> 20 | 21 | @Transaction 22 | @Query("SELECT * FROM $DOWNLOAD_TABLE WHERE detail_url=:detailUrl") 23 | fun getDownloadWithDownloadDetails(detailUrl: String): Flow 24 | 25 | @Insert(onConflict = OnConflictStrategy.REPLACE) 26 | suspend fun insertDownload(downloadEntity: DownloadEntity): Long 27 | 28 | @Query("DELETE FROM $DOWNLOAD_TABLE WHERE detail_url=:detailUrl") 29 | suspend fun deleteDownload(detailUrl: String) 30 | 31 | @Update 32 | suspend fun updateDownload(downloadEntity: DownloadEntity) 33 | 34 | @Query("SELECT * FROM $DOWNLOAD_TABLE WHERE detail_url=:detailUrl") 35 | fun getDownload(detailUrl: String): Flow 36 | 37 | @Query("SELECT * FROM $DOWNLOAD_TABLE WHERE detail_url=:detailUrl") 38 | fun checkDownload(detailUrl: String): Flow 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/dao/DownloadDetailDao.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Update 8 | import com.lanlinju.animius.data.local.entity.DownloadDetailEntity 9 | import com.lanlinju.animius.util.DOWNLOAD_DETAIL_TABLE 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Dao 13 | interface DownloadDetailDao { 14 | @Query("SELECT * FROM $DOWNLOAD_DETAIL_TABLE WHERE download_id=:downloadId ORDER BY drama_number ASC") 15 | fun getDownloadDetails(downloadId: Long): Flow> 16 | 17 | @Query("SELECT * FROM $DOWNLOAD_DETAIL_TABLE WHERE download_url=:downloadUrl") 18 | fun getDownloadDetail(downloadUrl: String): Flow 19 | 20 | @Insert(onConflict = OnConflictStrategy.IGNORE) 21 | suspend fun insertDownloadDetail(downloadDetailEntity: DownloadDetailEntity) 22 | 23 | @Query("DELETE FROM $DOWNLOAD_DETAIL_TABLE WHERE download_url=:downloadUrl") 24 | suspend fun deleteDownloadDetail(downloadUrl: String) 25 | 26 | @Update 27 | suspend fun updateDownloadDetail(downloadDetailEntity: DownloadDetailEntity) 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/dao/EpisodeDao.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Update 8 | import com.lanlinju.animius.data.local.entity.EpisodeEntity 9 | import com.lanlinju.animius.util.EPISODE_TABLE 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Dao 13 | interface EpisodeDao { 14 | 15 | @Query("SELECT * FROM $EPISODE_TABLE WHERE history_id=:historyId ORDER BY created_at DESC") 16 | fun getEpisodes(historyId: Long): Flow> 17 | 18 | @Query("SELECT * FROM $EPISODE_TABLE WHERE episode_url=:episodeUrl") 19 | fun getEpisode(episodeUrl: String): Flow 20 | 21 | @Query("SELECT * FROM $EPISODE_TABLE WHERE episode_url=:episodeUrl") 22 | fun checkEpisode(episodeUrl: String): Flow 23 | 24 | @Insert(onConflict = OnConflictStrategy.REPLACE) 25 | suspend fun insertEpisodes(episodes: List) 26 | 27 | @Insert(onConflict = OnConflictStrategy.REPLACE) 28 | suspend fun insertEpisode(episode: EpisodeEntity) 29 | 30 | @Query("DELETE FROM $EPISODE_TABLE") 31 | suspend fun deleteAll() 32 | 33 | @Update 34 | suspend fun updateEpisodes(episodes: List) 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/dao/FavouriteDao.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import com.lanlinju.animius.data.local.entity.FavouriteEntity 9 | import com.lanlinju.animius.util.FAVOURITE_TABLE 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Dao 13 | interface FavouriteDao { 14 | @Query("SELECT * FROM $FAVOURITE_TABLE ORDER BY created_at DESC") 15 | fun getAllFavourites(): Flow> 16 | 17 | @Query("SELECT * FROM $FAVOURITE_TABLE WHERE detail_url = :detailUrl") 18 | fun getFavouriteByDetailUrl(detailUrl: String): Flow 19 | 20 | @Query("SELECT * FROM $FAVOURITE_TABLE WHERE detail_url = :detailUrl") 21 | fun checkFavourite(detailUrl: String): Flow 22 | 23 | @Delete 24 | suspend fun deleteFavourite(favourite: FavouriteEntity) 25 | 26 | @Query("DELETE FROM $FAVOURITE_TABLE WHERE detail_url = :detailUrl") 27 | suspend fun deleteFavouriteByDetailUrl(detailUrl: String) 28 | 29 | @Insert(onConflict = OnConflictStrategy.REPLACE) 30 | suspend fun insertFavourite(favourite: FavouriteEntity) 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/dao/HistoryDao.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import androidx.room.Update 9 | import com.lanlinju.animius.data.local.entity.HistoryEntity 10 | import com.lanlinju.animius.data.local.relation.HistoryWithEpisodes 11 | import com.lanlinju.animius.util.HISTORY_TABLE 12 | import kotlinx.coroutines.flow.Flow 13 | 14 | @Dao 15 | interface HistoryDao { 16 | @Transaction 17 | @Query("SELECT * FROM $HISTORY_TABLE ORDER BY updated_at DESC") 18 | fun getHistoryWithEpisodes(): Flow> 19 | 20 | @Transaction 21 | @Query("SELECT * FROM $HISTORY_TABLE WHERE detail_url =:detailUrl") 22 | fun getHistoryWithEpisodes(detailUrl: String): Flow 23 | 24 | @Query("DELETE FROM $HISTORY_TABLE") 25 | suspend fun deleteAll() 26 | 27 | @Query("DELETE FROM $HISTORY_TABLE WHERE detail_url=:detailUrl") 28 | suspend fun deleteHistory(detailUrl: String) 29 | 30 | @Insert(onConflict = OnConflictStrategy.REPLACE) 31 | suspend fun insertHistories(history: List) 32 | 33 | @Insert(onConflict = OnConflictStrategy.REPLACE) 34 | suspend fun insertHistory(history: HistoryEntity): Long 35 | 36 | @Update 37 | suspend fun updateHistory(history: HistoryEntity) 38 | 39 | @Query("UPDATE $HISTORY_TABLE SET updated_at=:date WHERE detail_url=:detailUrl") 40 | suspend fun updateHistoryDate(detailUrl: String, date: Long = System.currentTimeMillis()) 41 | 42 | @Query("SELECT * FROM $HISTORY_TABLE WHERE detail_url=:detailUrl") 43 | fun getHistory(detailUrl: String): Flow 44 | 45 | @Query("SELECT * FROM $HISTORY_TABLE WHERE detail_url=:detailUrl") 46 | fun checkHistory(detailUrl: String): Flow 47 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/database/AnimeDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.lanlinju.animius.data.local.dao.DownloadDao 6 | import com.lanlinju.animius.data.local.dao.DownloadDetailDao 7 | import com.lanlinju.animius.data.local.dao.EpisodeDao 8 | import com.lanlinju.animius.data.local.dao.FavouriteDao 9 | import com.lanlinju.animius.data.local.dao.HistoryDao 10 | import com.lanlinju.animius.data.local.entity.DownloadDetailEntity 11 | import com.lanlinju.animius.data.local.entity.DownloadEntity 12 | import com.lanlinju.animius.data.local.entity.EpisodeEntity 13 | import com.lanlinju.animius.data.local.entity.FavouriteEntity 14 | import com.lanlinju.animius.data.local.entity.HistoryEntity 15 | 16 | @Database( 17 | version = 3, 18 | entities = [ 19 | FavouriteEntity::class, 20 | HistoryEntity::class, 21 | EpisodeEntity::class, 22 | DownloadEntity::class, 23 | DownloadDetailEntity::class 24 | ] 25 | ) 26 | abstract class AnimeDatabase : RoomDatabase() { 27 | abstract fun favouriteDao(): FavouriteDao 28 | abstract fun historyDao(): HistoryDao 29 | abstract fun episodeDao(): EpisodeDao 30 | abstract fun downLoadDao(): DownloadDao 31 | abstract fun downloadDetailDao(): DownloadDetailDao 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/entity/DownloadDetailEntity.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.ForeignKey 6 | import androidx.room.Index 7 | import androidx.room.PrimaryKey 8 | import com.lanlinju.animius.domain.model.DownloadDetail 9 | import com.lanlinju.animius.util.DOWNLOAD_DETAIL_TABLE 10 | 11 | @Entity( 12 | tableName = DOWNLOAD_DETAIL_TABLE, 13 | indices = [Index("download_id", unique = false), Index("download_url", unique = true)], 14 | foreignKeys = [ 15 | ForeignKey( 16 | entity = DownloadEntity::class, 17 | parentColumns = ["download_id"], 18 | childColumns = ["download_id"], 19 | onDelete = ForeignKey.CASCADE, 20 | onUpdate = ForeignKey.CASCADE 21 | ) 22 | ] 23 | ) 24 | data class DownloadDetailEntity( 25 | @PrimaryKey(autoGenerate = true) 26 | @ColumnInfo(name = "download_detail_id") val downloadDetailId: Long = 0, 27 | @ColumnInfo(name = "download_id") val downloadId: Long, 28 | @ColumnInfo(name = "title") val title: String, /* 剧集名 eg: 第01集 */ 29 | @ColumnInfo(name = "img_url") val imgUrl: String, 30 | @ColumnInfo(name = "drama_number") val dramaNumber: Int = 0, /* 用于集数排序 */ 31 | @ColumnInfo(name = "download_url") val downloadUrl: String, 32 | @ColumnInfo(name = "path") val path: String, /* 保存的文件路径 */ 33 | @ColumnInfo(name = "download_size") val downloadSize: Long = 0, /* 同下 */ 34 | @ColumnInfo(name = "total_size") val totalSize: Long = 0, /* 如果是m3u8类型则是分片数量,其他文件表示字节数 */ 35 | @ColumnInfo(name = "file_size") val fileSize: Long = 0, /* 在文件下载成功后写入其大小 */ 36 | @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis() 37 | ) { 38 | fun toDownloadDetail(): DownloadDetail { 39 | return DownloadDetail( 40 | title = title, 41 | imgUrl = imgUrl, 42 | dramaNumber = dramaNumber, 43 | downloadUrl = downloadUrl, 44 | path = path, 45 | downloadSize = downloadSize, 46 | totalSize = totalSize, 47 | fileSize = fileSize, 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/entity/DownloadEntity.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import com.lanlinju.animius.util.DOWNLOAD_TABLE 8 | 9 | @Entity( 10 | tableName = DOWNLOAD_TABLE, 11 | indices = [Index("detail_url", unique = true)] 12 | ) 13 | data class DownloadEntity( 14 | @PrimaryKey(autoGenerate = true) 15 | @ColumnInfo(name = "download_id") val downloadId: Long = 0, 16 | @ColumnInfo(name = "title") val title: String, 17 | @ColumnInfo(name = "detail_url") val detailUrl: String, 18 | @ColumnInfo(name = "img_url") val imgUrl: String, 19 | @ColumnInfo(name = "source") val source: String, 20 | @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis() 21 | ) 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/entity/EpisodeEntity.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.ForeignKey 6 | import androidx.room.Index 7 | import androidx.room.PrimaryKey 8 | import com.lanlinju.animius.domain.model.Episode 9 | import com.lanlinju.animius.util.EPISODE_TABLE 10 | 11 | /** 12 | * [onDelete = ForeignKey.CASCADE] 当父表中的某一条记录被删除时,子表中所有引用该记录的行也会自动被删除。 13 | * [onUpdate = ForeignKey.CASCADE] 当父表中的某一条记录的主键被更新时,子表中所有引用该主键的外键也会自动更新为新的值。 14 | */ 15 | @Entity( 16 | tableName = EPISODE_TABLE, 17 | indices = [Index("history_id", unique = false), Index("episode_url", unique = true)], 18 | foreignKeys = [ 19 | ForeignKey( 20 | entity = HistoryEntity::class, 21 | parentColumns = ["history_id"], 22 | childColumns = ["history_id"], 23 | onDelete = ForeignKey.CASCADE, 24 | onUpdate = ForeignKey.CASCADE 25 | ) 26 | ] 27 | ) 28 | data class EpisodeEntity( 29 | @PrimaryKey(autoGenerate = true) 30 | @ColumnInfo(name = "episode_id") val episodeId: Long = 0L, 31 | @ColumnInfo(name = "history_id") val historyId: Long, 32 | @ColumnInfo(name = "name") val name: String, 33 | @ColumnInfo(name = "episode_url") val episodeUrl: String, 34 | @ColumnInfo(name = "last_position") val lastPosition: Long = 0L, /* 记录上次视频播放的位置 */ 35 | @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis() 36 | ) { 37 | fun toEpisode(): Episode { 38 | return Episode( 39 | name = name, 40 | url = episodeUrl, 41 | lastPlayPosition = lastPosition, 42 | isPlayed = false, 43 | historyId = historyId 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/entity/FavouriteEntity.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import com.lanlinju.animius.domain.model.Favourite 8 | import com.lanlinju.animius.util.FAVOURITE_TABLE 9 | import com.lanlinju.animius.util.SourceMode 10 | 11 | @Entity( 12 | tableName = FAVOURITE_TABLE, 13 | indices = [Index("detail_url", unique = true)] 14 | ) 15 | data class FavouriteEntity( 16 | @PrimaryKey(autoGenerate = true) 17 | @ColumnInfo(name = "favourite_id") val favouriteId: Long = 0, 18 | @ColumnInfo(name = "title") val title: String, 19 | @ColumnInfo(name = "detail_url") val detailUrl: String, 20 | @ColumnInfo(name = "img_url") val imgUrl: String, 21 | @ColumnInfo(name = "source") val source: String, /* SourceMode 用于确定域名 */ 22 | @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis() 23 | ) { 24 | fun toFavourite(): Favourite { 25 | return Favourite( 26 | title = title, 27 | detailUrl = detailUrl, 28 | imgUrl = imgUrl, 29 | sourceMode = SourceMode.valueOf(source) 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/entity/HistoryEntity.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import com.lanlinju.animius.util.HISTORY_TABLE 8 | 9 | @Entity( 10 | tableName = HISTORY_TABLE, 11 | indices = [Index("detail_url", unique = true)] 12 | ) 13 | data class HistoryEntity( 14 | @PrimaryKey(autoGenerate = true) 15 | @ColumnInfo(name = "history_id") val historyId: Long = 0L, 16 | @ColumnInfo(name = "title") val title: String, 17 | @ColumnInfo(name = "img_url") val imgUrl: String, 18 | @ColumnInfo(name = "detail_url") val detailUrl: String, 19 | @ColumnInfo(name = "source") val source: String, /* 用于确定域名 */ 20 | @ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis() 21 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/relation/DownloadWithDownloadDetails.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.relation 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Relation 5 | import com.lanlinju.animius.data.local.entity.DownloadDetailEntity 6 | import com.lanlinju.animius.data.local.entity.DownloadEntity 7 | import com.lanlinju.animius.domain.model.Download 8 | import com.lanlinju.animius.util.SourceMode 9 | 10 | data class DownloadWithDownloadDetails( 11 | @Embedded val download: DownloadEntity, 12 | @Relation( 13 | parentColumn = "download_id", 14 | entityColumn = "download_id", 15 | ) 16 | val downloadDetails: List 17 | ) { 18 | fun toDownload(): Download { 19 | val totalSize = downloadDetails.fold(0L) { acc, d -> acc + d.fileSize } 20 | return Download( 21 | title = download.title, 22 | detailUrl = download.detailUrl, 23 | imgUrl = download.imgUrl, 24 | sourceMode = SourceMode.valueOf(download.source), 25 | totalSize = totalSize, 26 | downloadDetails = downloadDetails.map { it.toDownloadDetail() } 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/local/relation/HistoryWithEpisodes.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.local.relation 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Relation 5 | import com.lanlinju.animius.data.local.entity.EpisodeEntity 6 | import com.lanlinju.animius.data.local.entity.HistoryEntity 7 | import com.lanlinju.animius.domain.model.History 8 | import com.lanlinju.animius.util.SourceMode 9 | import java.text.SimpleDateFormat 10 | import java.util.Locale 11 | 12 | data class HistoryWithEpisodes( 13 | @Embedded val history: HistoryEntity, 14 | @Relation( 15 | parentColumn = "history_id", 16 | entityColumn = "history_id" 17 | ) 18 | val episodes: List 19 | ) { 20 | fun toHistory(): History { 21 | val sortedEpisodes = episodes.sortedByDescending { it.createdAt } 22 | val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) 23 | 24 | return History( 25 | title = history.title, 26 | imgUrl = history.imgUrl, 27 | detailUrl = history.detailUrl, 28 | lastEpisodeName = if (sortedEpisodes.isEmpty()) "" else sortedEpisodes.first().name, 29 | lastEpisodeUrl = if (sortedEpisodes.isEmpty()) "" else sortedEpisodes.first().episodeUrl, 30 | sourceMode = SourceMode.valueOf(history.source), 31 | time = simpleDateFormat.format(history.updatedAt), 32 | episodes = emptyList() 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/api/AnimeApi.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.api 2 | 3 | import com.lanlinju.animius.data.remote.dto.AnimeBean 4 | import com.lanlinju.animius.data.remote.dto.AnimeDetailBean 5 | import com.lanlinju.animius.data.remote.dto.HomeBean 6 | import com.lanlinju.animius.data.remote.dto.VideoBean 7 | import com.lanlinju.animius.util.SourceMode 8 | 9 | interface AnimeApi { 10 | suspend fun getHomeAllData(): List 11 | 12 | suspend fun getAnimeDetail(detailUrl: String, mode: SourceMode): AnimeDetailBean 13 | 14 | suspend fun getVideoData(episodeUrl: String, mode: SourceMode): VideoBean 15 | 16 | suspend fun getSearchData(query: String, page: Int, mode: SourceMode): List 17 | 18 | suspend fun getWeekDate(): Map> 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/api/AnimeApiImpl.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.api 2 | 3 | import com.lanlinju.animius.data.remote.dto.AnimeBean 4 | import com.lanlinju.animius.data.remote.dto.AnimeDetailBean 5 | import com.lanlinju.animius.data.remote.dto.HomeBean 6 | import com.lanlinju.animius.data.remote.dto.VideoBean 7 | import com.lanlinju.animius.util.SourceHolder 8 | import com.lanlinju.animius.util.SourceMode 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class AnimeApiImpl @Inject constructor() : AnimeApi { 14 | override suspend fun getHomeAllData(): List { 15 | val animeSource = SourceHolder.currentSource 16 | return animeSource.getHomeData() 17 | } 18 | 19 | override suspend fun getAnimeDetail(detailUrl: String, mode: SourceMode): AnimeDetailBean { 20 | val animeSource = SourceHolder.getSource(mode) 21 | return animeSource.getAnimeDetail(detailUrl) 22 | } 23 | 24 | override suspend fun getVideoData(episodeUrl: String, mode: SourceMode): VideoBean { 25 | val animeSource = SourceHolder.getSource(mode) 26 | return animeSource.getVideoData(episodeUrl) 27 | } 28 | 29 | override suspend fun getSearchData(query: String, page: Int, mode: SourceMode): List { 30 | val animeSource = SourceHolder.getSource(mode) 31 | return animeSource.getSearchData(query, page) 32 | } 33 | 34 | override suspend fun getWeekDate(): Map> { 35 | val animeSource = SourceHolder.currentSource 36 | return animeSource.getWeekData() 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dandanplay/DandanplayClient.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dandanplay 2 | 3 | import com.lanlinju.animius.BuildConfig 4 | import com.lanlinju.animius.data.remote.dandanplay.dto.DandanplayDanmaku 5 | import com.lanlinju.animius.data.remote.dandanplay.dto.DandanplayDanmakuListResponse 6 | import com.lanlinju.animius.data.remote.dandanplay.dto.DandanplaySearchEpisodeResponse 7 | import io.ktor.client.HttpClient 8 | import io.ktor.client.call.body 9 | import io.ktor.client.plugins.timeout 10 | import io.ktor.client.request.HttpRequestBuilder 11 | import io.ktor.client.request.accept 12 | import io.ktor.client.request.get 13 | import io.ktor.client.request.header 14 | import io.ktor.client.request.parameter 15 | import io.ktor.http.ContentType 16 | import io.ktor.http.encodedPath 17 | import java.lang.System.currentTimeMillis 18 | import java.security.MessageDigest 19 | import kotlin.io.encoding.Base64 20 | import kotlin.io.encoding.ExperimentalEncodingApi 21 | 22 | class DandanplayClient( 23 | private val client: HttpClient, 24 | private val appId: String = BuildConfig.DANDANPLAY_APP_ID, 25 | private val appSecret: String = BuildConfig.DANDANPLAY_APP_SECRET, 26 | ) { 27 | 28 | suspend fun searchEpisode( 29 | subjectName: String, 30 | episodeName: String?, 31 | ): DandanplaySearchEpisodeResponse { 32 | val response = client.get("https://api.dandanplay.net/api/v2/search/episodes") { 33 | configureTimeout() 34 | accept(ContentType.Application.Json) 35 | addAuthorizationHeaders() 36 | parameter("anime", subjectName) 37 | parameter("episode", episodeName) 38 | } 39 | 40 | return response.body() 41 | } 42 | 43 | suspend fun getDanmakuList(episodeId: Long): List { 44 | val chConvert = 0 45 | val response = 46 | client.get("https://api.dandanplay.net/api/v2/comment/${episodeId}?chConvert=$chConvert&withRelated=true") { 47 | configureTimeout() 48 | accept(ContentType.Application.Json) 49 | addAuthorizationHeaders() 50 | }.body() 51 | 52 | return response.comments 53 | } 54 | 55 | private fun HttpRequestBuilder.addAuthorizationHeaders() { 56 | val timestamp = currentTimeMillis() / 1000 57 | header("X-AppId", appId) 58 | header("X-Timestamp", timestamp) 59 | header("X-Signature", generateSignature(appId, timestamp, url.encodedPath, appSecret)) 60 | } 61 | 62 | private fun HttpRequestBuilder.configureTimeout() { 63 | timeout { 64 | requestTimeoutMillis = 60_000 65 | connectTimeoutMillis = 60_000 66 | socketTimeoutMillis = 60_000 67 | } 68 | } 69 | 70 | @OptIn(ExperimentalEncodingApi::class) 71 | private fun generateSignature( 72 | appId: String, 73 | timestamp: Long, 74 | path: String, 75 | appSecret: String 76 | ): String { 77 | val data = appId + timestamp + path + appSecret 78 | val hash = MessageDigest.getInstance("SHA-256").digest(data.toByteArray()) 79 | return Base64.encode(hash) 80 | } 81 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dandanplay/DandanplayDanmakuProvider.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dandanplay 2 | 3 | import com.anime.danmaku.api.DanmakuSession 4 | import com.anime.danmaku.api.TimeBasedDanmakuSession 5 | import com.lanlinju.animius.data.remote.dandanplay.DandanplayDanmakuProvider.Companion.ID 6 | import com.lanlinju.animius.data.remote.dandanplay.dto.toDanmakuOrNull 7 | import com.lanlinju.animius.util.createHttpClient 8 | import io.ktor.client.HttpClient 9 | import kotlinx.coroutines.Dispatchers 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | /** 14 | * A [DanmakuProvider] provides a stream of danmaku for a specific episode. 15 | * 16 | * @see DanmakuProviderFactory 17 | */ 18 | interface DanmakuProvider : AutoCloseable { 19 | // 弹幕提供者的唯一标识符 20 | val id: String 21 | 22 | // 挂起函数,用于获取弹幕会话 23 | suspend fun fetch(subjectName: String, episodeName: String?): DanmakuSession? 24 | } 25 | 26 | interface DanmakuProviderFactory { // SPI 接口 27 | /** 28 | * @see DanmakuProvider.id 29 | * 获取弹幕提供者的唯一标识符 30 | */ 31 | val id: String 32 | 33 | // 创建一个新的弹幕提供者实例 34 | fun create(): DanmakuProvider 35 | } 36 | 37 | @Singleton 38 | class DandanplayDanmakuProvider @Inject constructor( 39 | private val client: HttpClient 40 | ) : DanmakuProvider { 41 | 42 | companion object { 43 | const val ID = "弹弹play" 44 | } 45 | 46 | override val id: String get() = ID 47 | 48 | private val dandanplayClient = DandanplayClient(client) 49 | private val moviePattern = Regex("全集|HD|正片") 50 | private val nonDigitRegex = Regex("\\D") 51 | 52 | override suspend fun fetch( 53 | subjectName: String, episodeName: String? 54 | ): DanmakuSession? { 55 | if (episodeName.isNullOrBlank()) return null 56 | val formattedEpisodeName = episodeName.let { name -> 57 | when { 58 | moviePattern.containsMatchIn(name) -> "movie" // 剧场版 59 | name.contains("第") -> name.replace(nonDigitRegex, "") // tv 第01集 -> 01 60 | name.matches(Regex("\\d+")) -> name // girigiri tv的剧集只有数字 61 | else -> return null // 只获取TV版和剧场版弹幕 62 | } 63 | } 64 | 65 | val searchEpisodeResponse = 66 | dandanplayClient.searchEpisode(subjectName, formattedEpisodeName) 67 | 68 | if (!searchEpisodeResponse.success || searchEpisodeResponse.animes.isEmpty()) { 69 | return null 70 | } 71 | val firstAnime = searchEpisodeResponse.animes[0] 72 | val episodes = firstAnime.episodes 73 | if (episodes.isEmpty()) { 74 | return null 75 | } 76 | val firstEpisode = episodes[0] 77 | val episodeId = firstEpisode.episodeId.toLong() 78 | 79 | return createSession(episodeId) 80 | } 81 | 82 | private suspend fun createSession( 83 | episodeId: Long, 84 | ): DanmakuSession { 85 | val list = dandanplayClient.getDanmakuList(episodeId = episodeId) 86 | return TimeBasedDanmakuSession.create( 87 | list.asSequence().mapNotNull { it.toDanmakuOrNull() }, 88 | coroutineContext = Dispatchers.Default, 89 | ) 90 | } 91 | 92 | override fun close() { 93 | //client.close() 94 | } 95 | } 96 | 97 | class DandanplayDanmakuProviderFactory : DanmakuProviderFactory { 98 | override val id: String get() = ID 99 | 100 | override fun create(): DandanplayDanmakuProvider { 101 | return DandanplayDanmakuProvider(createHttpClient()) 102 | } 103 | } 104 | 105 | 106 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dandanplay/dto/DandanplayDanmaku.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dandanplay.dto 2 | 3 | import com.anime.danmaku.api.Danmaku 4 | import com.anime.danmaku.api.DanmakuLocation 5 | import com.lanlinju.animius.data.remote.dandanplay.DandanplayDanmakuProvider 6 | import kotlinx.serialization.Serializable 7 | 8 | @Serializable 9 | class DandanplayDanmaku( 10 | val cid: Long, 11 | val p: String, 12 | val m: String, // content 13 | ) 14 | 15 | fun DandanplayDanmaku.toDanmakuOrNull(): Danmaku? { 16 | /* 17 | p参数格式为出现时间,模式,颜色,用户ID,各个参数之间使用英文逗号分隔 18 | 弹幕出现时间:格式为 0.00,单位为秒,精确到小数点后两位,例如12.34、445.6、789.01 19 | 弹幕模式:1-普通弹幕,4-底部弹幕,5-顶部弹幕 20 | 颜色:32位整数表示的颜色,算法为 Rx256x256+Gx256+B,R/G/B的范围应是0-255 21 | 用户ID:字符串形式表示的用户ID,通常为数字,不会包含特殊字符 22 | */ 23 | val (time, mode, color, userId) = p.split(",").let { 24 | if (it.size < 4) return null else it 25 | } 26 | 27 | val timeSecs = time.toDoubleOrNull() ?: return null 28 | 29 | return Danmaku( 30 | id = cid.toString(), 31 | providerId = DandanplayDanmakuProvider.ID, 32 | playTimeMillis = (timeSecs * 1000).toLong(), 33 | senderId = userId, 34 | location = when (mode.toIntOrNull()) { 35 | 1 -> DanmakuLocation.NORMAL 36 | 4 -> DanmakuLocation.BOTTOM 37 | 5 -> DanmakuLocation.TOP 38 | else -> return null 39 | }, 40 | text = m, 41 | color = color.toIntOrNull() ?: return null 42 | ) 43 | } 44 | 45 | @Serializable 46 | class DandanplayDanmakuListResponse( 47 | val count: Int, 48 | val comments: List 49 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dandanplay/dto/DandanplaySearchEpisodeResponse.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dandanplay.dto 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class DandanplaySearchEpisodeResponse( 7 | val hasMore: Boolean = false, 8 | val animes: List = listOf(), 9 | val errorCode: Int = 0, 10 | val success: Boolean = true, 11 | val errorMessage: String? = null, 12 | ) 13 | 14 | @Serializable 15 | data class SearchAnimeEpisodes( 16 | val animeId: Int, 17 | val animeTitle: String, 18 | val type: String, 19 | val typeDescription: String, 20 | val episodes: List = listOf(), 21 | ) 22 | 23 | @Serializable 24 | data class SearchEpisodeDetails( 25 | val episodeId: Int, 26 | val episodeTitle: String, 27 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dto/AnimeBean.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dto 2 | 3 | import com.lanlinju.animius.domain.model.Anime 4 | 5 | /** 6 | * @param title 动漫名称 7 | * @param img 图片url /* 获取时间表时可为空 */ 8 | * @param url 动漫详情url 9 | * @param episodeName 集数 10 | */ 11 | data class AnimeBean( 12 | val title: String, 13 | val img: String, 14 | val url: String, 15 | val episodeName: String = "" 16 | ) { 17 | fun toAnime(): Anime { 18 | return Anime( 19 | title = title, 20 | img = img, 21 | detailUrl = url, 22 | episodeName = episodeName 23 | ) 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dto/AnimeDetailBean.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dto 2 | 3 | import com.lanlinju.animius.domain.model.AnimeDetail 4 | 5 | data class AnimeDetailBean( 6 | val title: String, 7 | val imgUrl: String, 8 | val desc: String, 9 | val tags: List = emptyList(), 10 | val relatedAnimes: List, 11 | val episodes: List = emptyList(), /* 保持对旧的数据兼容, 如果支持多线路则需要置为空 */ 12 | val channels: Map> = emptyMap(), /* 剧集多线路支持 */ 13 | ) { 14 | fun toAnimeDetail(): AnimeDetail { 15 | val tempChannels = if (episodes.isNotEmpty()) { /* 保持对旧的不支持多线路的兼容 */ 16 | mapOf(0 to episodes.map { it.toEpisode() }) 17 | } else { 18 | channels.mapValues { it.value.map { it.toEpisode() } } 19 | } 20 | return AnimeDetail( 21 | title = title, 22 | img = imgUrl, 23 | desc = desc, 24 | tags = tags.map { it.uppercase() }, 25 | lastPosition = 0, 26 | episodes = tempChannels[0] ?: emptyList(), 27 | relatedAnimes = relatedAnimes.map { it.toAnime() }, 28 | channels = tempChannels, 29 | ) 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dto/EpisodeBean.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dto 2 | 3 | import com.lanlinju.animius.domain.model.Episode 4 | 5 | data class EpisodeBean( 6 | val name: String, 7 | val url: String 8 | ) { 9 | fun toEpisode(): Episode { 10 | return Episode(name = name, url = url) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dto/HomeBean.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dto 2 | 3 | import com.lanlinju.animius.domain.model.Home 4 | 5 | data class HomeBean( 6 | val title: String, 7 | val moreUrl: String = "", // 可为空 8 | val animes: List 9 | ) { 10 | fun toHome(): Home { 11 | val homeItems = animes.map { it.toAnime() } 12 | return Home(title = title, animeList = homeItems) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/dto/VideoBean.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.dto 2 | 3 | import com.lanlinju.animius.domain.model.WebVideo 4 | 5 | data class VideoBean( 6 | val videoUrl: String, /* 视频播放地址 */ 7 | val headers: Map = emptyMap() 8 | ) { 9 | fun toWebVideo(): WebVideo { 10 | return WebVideo( 11 | url = videoUrl, 12 | headers = headers 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/remote/parse/AnimeSource.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.remote.parse 2 | 3 | import com.lanlinju.animius.data.remote.dto.AnimeBean 4 | import com.lanlinju.animius.data.remote.dto.AnimeDetailBean 5 | import com.lanlinju.animius.data.remote.dto.HomeBean 6 | import com.lanlinju.animius.data.remote.dto.VideoBean 7 | import com.lanlinju.animius.util.preferences 8 | 9 | interface AnimeSource { 10 | 11 | /** 12 | * [preferences] 的Key值用于获取用户的自定义的域名 13 | */ 14 | val KEY_SOURCE_DOMAIN: String 15 | get() = "${this.javaClass.simpleName}Domain" 16 | 17 | /** 18 | * 默认动漫域名 19 | */ 20 | val DEFAULT_DOMAIN: String 21 | 22 | /** 23 | * 动漫域名,默认值为[DEFAULT_DOMAIN], 24 | * 且[DEFAULT_DOMAIN] 要先于 [baseUrl] 初始化 25 | */ 26 | var baseUrl: String 27 | 28 | suspend fun getHomeData(): List 29 | 30 | suspend fun getAnimeDetail(detailUrl: String): AnimeDetailBean 31 | 32 | suspend fun getVideoData(episodeUrl: String): VideoBean 33 | 34 | suspend fun getSearchData(query: String, page: Int): List 35 | 36 | suspend fun getWeekData(): Map> 37 | 38 | /** 39 | * 当切换选中的数据源时调用,可以执行一些初始化操作 40 | */ 41 | fun onEnter() {} 42 | 43 | /** 44 | * 当退出当前数据源时调用,可以执行一些清理操作 45 | */ 46 | fun onExit() {} 47 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/repository/AnimeRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.repository 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import com.lanlinju.animius.data.remote.api.AnimeApi 7 | import com.lanlinju.animius.data.repository.paging.SearchPagingSource 8 | import com.lanlinju.animius.domain.model.Anime 9 | import com.lanlinju.animius.domain.model.AnimeDetail 10 | import com.lanlinju.animius.domain.model.Home 11 | import com.lanlinju.animius.domain.model.WebVideo 12 | import com.lanlinju.animius.domain.repository.AnimeRepository 13 | import com.lanlinju.animius.util.Resource 14 | import com.lanlinju.animius.util.Result 15 | import com.lanlinju.animius.util.SEARCH_PAGE_SIZE 16 | import com.lanlinju.animius.util.SourceMode 17 | import com.lanlinju.animius.util.invokeApi 18 | import com.lanlinju.animius.util.map 19 | import com.lanlinju.animius.util.safeCall 20 | import kotlinx.coroutines.flow.Flow 21 | import javax.inject.Inject 22 | import javax.inject.Singleton 23 | 24 | @Singleton 25 | class AnimeRepositoryImpl @Inject constructor( 26 | private val animeApi: AnimeApi 27 | ) : AnimeRepository { 28 | override suspend fun getHomeData(): Resource> { 29 | val response = invokeApi { 30 | animeApi.getHomeAllData() 31 | } 32 | return when (response) { 33 | is Resource.Error -> Resource.Error(error = response.error) 34 | is Resource.Loading -> Resource.Loading 35 | is Resource.Success -> Resource.Success( 36 | data = response.data?.map { it.toHome() }.orEmpty() 37 | ) 38 | } 39 | } 40 | 41 | override suspend fun getAnimeDetail( 42 | detailUrl: String, 43 | mode: SourceMode 44 | ): Resource { 45 | val response = invokeApi { 46 | animeApi.getAnimeDetail(detailUrl, mode) 47 | } 48 | return when (val response = response) { 49 | is Resource.Error -> Resource.Error(error = response.error) 50 | is Resource.Loading -> Resource.Loading 51 | is Resource.Success -> Resource.Success( 52 | data = response.data?.toAnimeDetail() 53 | ) 54 | } 55 | } 56 | 57 | override suspend fun getVideoData(episodeUrl: String, mode: SourceMode): Result { 58 | return safeCall { 59 | animeApi.getVideoData(episodeUrl, mode) 60 | }.map { it.toWebVideo() } 61 | } 62 | 63 | override suspend fun getSearchData(query: String, mode: SourceMode): Flow> { 64 | return Pager( 65 | config = PagingConfig(pageSize = SEARCH_PAGE_SIZE), 66 | pagingSourceFactory = { SearchPagingSource(api = animeApi, query, mode) } 67 | ).flow 68 | } 69 | 70 | override suspend fun getWeekData(): Resource>> { 71 | val response = invokeApi { 72 | animeApi.getWeekDate() 73 | } 74 | return when (response) { 75 | is Resource.Error -> Resource.Error(error = response.error) 76 | is Resource.Loading -> Resource.Loading 77 | is Resource.Success -> Resource.Success( 78 | data = response.data?.mapValues { (_, v) -> v.map { it.toAnime() } } ?: emptyMap() 79 | ) 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/repository/DanmakuRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.repository 2 | 3 | import com.anime.danmaku.api.DanmakuSession 4 | import com.lanlinju.animius.data.remote.dandanplay.DanmakuProvider 5 | import com.lanlinju.animius.domain.repository.DanmakuRepository 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class DanmakuRepositoryImpl @Inject constructor( 11 | private val danmakuProvider: DanmakuProvider 12 | ) : DanmakuRepository { 13 | override suspend fun fetchDanmakuSession( 14 | subjectName: String, 15 | episodeName: String? 16 | ): DanmakuSession? { 17 | return try { 18 | danmakuProvider.fetch(subjectName, episodeName) 19 | } catch (_: Exception) { 20 | null 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/data/repository/paging/SearchPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.data.repository.paging 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.lanlinju.animius.data.remote.api.AnimeApi 6 | import com.lanlinju.animius.domain.model.Anime 7 | import com.lanlinju.animius.util.SourceMode 8 | 9 | class SearchPagingSource( 10 | private val api: AnimeApi, 11 | private val query: String, 12 | private val mode: SourceMode, 13 | ) : PagingSource() { 14 | override fun getRefreshKey(state: PagingState): Int? { 15 | return state.anchorPosition 16 | } 17 | 18 | override suspend fun load(params: LoadParams): LoadResult { 19 | val currentPage = params.key ?: 1 20 | 21 | return try { 22 | val response = api.getSearchData(query, currentPage, mode) 23 | 24 | val endOfPaginationReached = response.isEmpty() 25 | 26 | LoadResult.Page( 27 | data = response.map { it.toAnime() }, 28 | prevKey = if (currentPage == 1) null else currentPage - 1, 29 | nextKey = if (endOfPaginationReached) null else currentPage + 1 30 | ) 31 | } catch (exp: Exception) { 32 | LoadResult.Error(exp) 33 | } 34 | 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/di/ApiModule.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.di 2 | 3 | import com.lanlinju.animius.data.remote.api.AnimeApi 4 | import com.lanlinju.animius.data.remote.api.AnimeApiImpl 5 | import com.lanlinju.animius.data.remote.dandanplay.DandanplayDanmakuProvider 6 | import com.lanlinju.animius.data.remote.dandanplay.DanmakuProvider 7 | import com.lanlinju.animius.data.repository.AnimeRepositoryImpl 8 | import com.lanlinju.animius.data.repository.DanmakuRepositoryImpl 9 | import com.lanlinju.animius.domain.repository.AnimeRepository 10 | import com.lanlinju.animius.domain.repository.DanmakuRepository 11 | import dagger.Binds 12 | import dagger.Module 13 | import dagger.hilt.InstallIn 14 | import dagger.hilt.components.SingletonComponent 15 | import javax.inject.Singleton 16 | 17 | @Module 18 | @InstallIn(SingletonComponent::class) 19 | abstract class ApiModule { 20 | @Singleton 21 | @Binds 22 | abstract fun providesAnimeApi(animeApiImpl: AnimeApiImpl): AnimeApi 23 | 24 | @Singleton 25 | @Binds 26 | abstract fun providesAnimeRepository(animeRepositoryImpl: AnimeRepositoryImpl): AnimeRepository 27 | 28 | @Singleton 29 | @Binds 30 | abstract fun provideDandanplayProvider(dandanplayDanmakuProvider: DandanplayDanmakuProvider): DanmakuProvider 31 | 32 | @Singleton 33 | @Binds 34 | abstract fun provideDanmakuRepository(danmakuRepositoryImpl: DanmakuRepositoryImpl): DanmakuRepository 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | import androidx.room.Room 7 | import com.lanlinju.animius.application.AnimeApplication 8 | import com.lanlinju.animius.data.local.database.AnimeDatabase 9 | import com.lanlinju.animius.data.repository.RoomRepositoryImpl 10 | import com.lanlinju.animius.domain.repository.RoomRepository 11 | import com.lanlinju.animius.util.ANIME_DATABASE 12 | import com.lanlinju.animius.util.DownloadManager 13 | import com.lanlinju.animius.util.preferences 14 | import dagger.Module 15 | import dagger.Provides 16 | import dagger.hilt.InstallIn 17 | import dagger.hilt.android.qualifiers.ApplicationContext 18 | import dagger.hilt.components.SingletonComponent 19 | import io.ktor.client.HttpClient 20 | import javax.inject.Singleton 21 | 22 | 23 | @Module 24 | @InstallIn(SingletonComponent::class) 25 | object AppModule { 26 | 27 | @Singleton 28 | @Provides 29 | fun providesAnimeApplication( 30 | @ApplicationContext app: Context 31 | ): AnimeApplication { 32 | return app as AnimeApplication 33 | } 34 | 35 | @Singleton 36 | @Provides 37 | fun providesContext( 38 | @ApplicationContext app: Context 39 | ): Context { 40 | return app 41 | } 42 | 43 | @Singleton 44 | @Provides // The Application binding is available without qualifiers. 45 | fun providesDatabase(application: Application): AnimeDatabase { 46 | return Room.databaseBuilder( 47 | application, 48 | AnimeDatabase::class.java, 49 | ANIME_DATABASE, 50 | ).fallbackToDestructiveMigration() 51 | .build() 52 | } 53 | 54 | @Singleton 55 | @Provides 56 | fun providesRoomRepository(database: AnimeDatabase): RoomRepository { 57 | return RoomRepositoryImpl(database) 58 | } 59 | 60 | @Singleton 61 | @Provides 62 | fun providesPreferences(@ApplicationContext context: Context): SharedPreferences { 63 | return context.preferences 64 | } 65 | 66 | @Singleton 67 | @Provides 68 | fun provideHttpClient(): HttpClient { 69 | return DownloadManager.httpClient 70 | } 71 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/Anime.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | data class Anime( 4 | val title: String, 5 | val img: String, 6 | val detailUrl: String, 7 | val episodeName: String 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/AnimeDetail.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | 4 | data class AnimeDetail( 5 | val title: String, 6 | val img: String, 7 | val desc: String, 8 | val tags: List, 9 | val lastPosition: Int, 10 | val episodes: List, 11 | val relatedAnimes: List, 12 | val channelIndex: Int = 0, 13 | val channels: Map> = emptyMap(), 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/Download.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | import com.lanlinju.animius.data.local.entity.DownloadEntity 4 | import com.lanlinju.animius.util.SourceMode 5 | 6 | data class Download( 7 | val title: String, 8 | val detailUrl: String, 9 | val imgUrl: String, 10 | val sourceMode: SourceMode, 11 | val totalSize: Long = 0, /* 已下载完成的全部剧集大小,单位字节 */ 12 | val downloadDetails: List 13 | ) { 14 | fun toDownloadEntity(): DownloadEntity { 15 | return DownloadEntity( 16 | title = title, 17 | detailUrl = detailUrl, 18 | imgUrl = imgUrl, 19 | source = sourceMode.name, 20 | ) 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/DownloadDetail.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | import com.lanlinju.animius.data.local.entity.DownloadDetailEntity 4 | 5 | data class DownloadDetail( 6 | val title: String, /* 剧集名 eg: 第01集 */ 7 | val imgUrl: String, 8 | val dramaNumber: Int, /* 用于集数排序 */ 9 | val downloadUrl: String, 10 | val path: String, /* 保存的文件路径 */ 11 | val downloadSize: Long = 0, /* 同下 */ 12 | val totalSize: Long = 0, /* 如果是m3u8类型则是分片数量,其他文件表示字节数 */ 13 | val fileSize: Long = 0, /* 在文件下载成功后写入其大小 */ 14 | ) { 15 | fun toDownloadDetailEntity(downloadId: Long): DownloadDetailEntity { 16 | return DownloadDetailEntity( 17 | downloadId = downloadId, 18 | title = title, 19 | imgUrl = imgUrl, 20 | downloadUrl = downloadUrl, 21 | dramaNumber = dramaNumber, 22 | path = path, 23 | downloadSize = downloadSize, 24 | totalSize = totalSize, 25 | fileSize = fileSize, 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/Episode.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | import com.lanlinju.animius.data.local.entity.EpisodeEntity 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class Episode( 8 | val name: String, 9 | val url: String, /* 如果播放本地视频是表示视频url,否则则是剧集url,需要在对应剧集页面解析视频url */ 10 | val lastPlayPosition: Long = 0L, /* 记录上次播放位置 */ 11 | val isPlayed: Boolean = false, /* 用于标记是否已经播放过 */ 12 | val isDownloaded: Boolean = false, /* 用于标记是否已经加入下载列表 */ 13 | val historyId: Long = 0L, 14 | ) { 15 | fun toEpisodeEntity(): EpisodeEntity { 16 | return EpisodeEntity( 17 | name = name, 18 | episodeUrl = url, 19 | historyId = historyId, 20 | lastPosition = lastPlayPosition 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/Favourite.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | import com.lanlinju.animius.data.local.entity.FavouriteEntity 4 | import com.lanlinju.animius.util.SourceMode 5 | 6 | data class Favourite( 7 | val title: String, 8 | val detailUrl: String, 9 | val imgUrl: String, 10 | val sourceMode: SourceMode 11 | ) { 12 | fun toFavouriteEntity(): FavouriteEntity { 13 | return FavouriteEntity( 14 | title = title, 15 | detailUrl = detailUrl, 16 | imgUrl = imgUrl, 17 | source = sourceMode.name 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/History.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | import com.lanlinju.animius.data.local.entity.HistoryEntity 4 | import com.lanlinju.animius.util.SourceMode 5 | 6 | data class History( 7 | val title: String, 8 | val imgUrl: String, 9 | val detailUrl: String, 10 | val lastEpisodeName: String = "", 11 | val lastEpisodeUrl: String = "", 12 | val sourceMode: SourceMode, 13 | val time: String = "", 14 | val episodes: List 15 | ) { 16 | fun toHistoryEntity(): HistoryEntity { 17 | return HistoryEntity( 18 | title = title, 19 | imgUrl = imgUrl, 20 | detailUrl = detailUrl, 21 | source = sourceMode.name 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/Home.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | data class Home( 4 | val title: String, 5 | val animeList: List 6 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/Video.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | data class Video( 4 | val title: String, 5 | val url: String, /* 视频播放地址 */ 6 | val episodeName: String, 7 | val episodeUrl: String, /* 当前播放的剧集url */ 8 | val lastPlayPosition: Long = 0L, /* 记忆播放视频位置,单位:毫秒 */ 9 | val currentEpisodeIndex: Int, /* 当前播放剧集索引,根据[episodeName]计算得出*/ 10 | val episodes: List, 11 | val headers: Map = emptyMap() /* 用于配置Referer, User-Agent等*/ 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/model/WebVideo.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.model 2 | 3 | data class WebVideo( 4 | val url: String, /* 视频播放地址 */ 5 | val headers: Map = emptyMap() /* 用于配置Referer, User-Agent等*/ 6 | ) 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/repository/AnimeRepository.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.repository 2 | 3 | import androidx.paging.PagingData 4 | import com.lanlinju.animius.domain.model.Anime 5 | import com.lanlinju.animius.domain.model.AnimeDetail 6 | import com.lanlinju.animius.domain.model.Home 7 | import com.lanlinju.animius.domain.model.WebVideo 8 | import com.lanlinju.animius.util.Resource 9 | import com.lanlinju.animius.util.Result 10 | import com.lanlinju.animius.util.SourceMode 11 | import kotlinx.coroutines.flow.Flow 12 | 13 | interface AnimeRepository { 14 | suspend fun getHomeData(): Resource> 15 | 16 | suspend fun getAnimeDetail(detailUrl: String, mode: SourceMode): Resource 17 | 18 | suspend fun getVideoData(episodeUrl: String, mode: SourceMode): Result 19 | 20 | suspend fun getSearchData(query: String, mode: SourceMode): Flow> 21 | 22 | suspend fun getWeekData(): Resource>> 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/repository/DanmakuRepository.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.repository 2 | 3 | import com.anime.danmaku.api.DanmakuSession 4 | 5 | interface DanmakuRepository { 6 | suspend fun fetchDanmakuSession(subjectName: String, episodeName: String?): DanmakuSession? 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/repository/RoomRepository.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.repository 2 | 3 | import com.lanlinju.animius.domain.model.Download 4 | import com.lanlinju.animius.domain.model.DownloadDetail 5 | import com.lanlinju.animius.domain.model.Episode 6 | import com.lanlinju.animius.domain.model.Favourite 7 | import com.lanlinju.animius.domain.model.History 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | interface RoomRepository { 11 | suspend fun getFavourites(): Flow> 12 | 13 | suspend fun addOrRemoveFavourite(favourite: Favourite) 14 | 15 | suspend fun checkFavourite(detailUrl: String): Flow 16 | 17 | suspend fun removeFavourite(detailUrl: String) 18 | 19 | suspend fun addHistory(history: History) 20 | 21 | suspend fun checkHistory(detailUrl: String): Flow 22 | 23 | suspend fun getHistories(): Flow> 24 | 25 | suspend fun deleteHistory(detailUrl: String) 26 | 27 | suspend fun updateHistoryDate(detailUrl: String) 28 | 29 | suspend fun deleteAllHistories() 30 | 31 | suspend fun getEpisodes(detailUrl: String): Flow> 32 | 33 | suspend fun getEpisode(episodeUrl: String): Flow 34 | 35 | suspend fun addEpisode(episode: Episode) 36 | 37 | suspend fun getDownloads(): Flow> 38 | 39 | suspend fun addDownload(download: Download) 40 | 41 | suspend fun deleteDownload(detailUrl: String) 42 | 43 | suspend fun checkDownload(detailUrl: String): Flow 44 | 45 | suspend fun getDownloadDetails(detailUrl: String): Flow> 46 | 47 | suspend fun updateDownloadDetail(downloadDetail: DownloadDetail) 48 | 49 | suspend fun deleteDownloadDetail(downloadUrl: String) 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/domain/usecase/GetAnimeDetailUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.domain.usecase 2 | 3 | import com.lanlinju.animius.domain.model.AnimeDetail 4 | import com.lanlinju.animius.domain.model.Episode 5 | import com.lanlinju.animius.domain.repository.AnimeRepository 6 | import com.lanlinju.animius.domain.repository.RoomRepository 7 | import com.lanlinju.animius.util.Resource 8 | import com.lanlinju.animius.util.SourceMode 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.flow 11 | import javax.inject.Inject 12 | 13 | class GetAnimeDetailUseCase @Inject constructor( 14 | private val animeRepository: AnimeRepository, 15 | private val roomRepository: RoomRepository 16 | ) { 17 | suspend operator fun invoke(detailUrl: String, mode: SourceMode): Flow> { 18 | return flow { 19 | when (val resource = animeRepository.getAnimeDetail(detailUrl, mode)) { 20 | is Resource.Error -> emit(Resource.Error(error = resource.error)) 21 | is Resource.Loading -> emit(Resource.Loading) 22 | is Resource.Success -> { 23 | try { 24 | roomRepository.checkHistory(detailUrl).collect { isStoredHistory -> 25 | if (!isStoredHistory) { 26 | emit(Resource.Success(data = resource.data)) 27 | } else { 28 | roomRepository.getEpisodes(detailUrl).collect { localEpisodes -> 29 | if (localEpisodes.isEmpty()) { 30 | emit(Resource.Success(data = resource.data)) 31 | } else { 32 | val lastPlayedEpisode = localEpisodes.first() 33 | val remoteEpisodes = resource.data!!.episodes 34 | 35 | val lastPosition = 36 | remoteEpisodes.indexOfFirst { it.url == lastPlayedEpisode.url } 37 | val episodeList = remoteEpisodes.map { episode -> 38 | val index = 39 | localEpisodes.indexOfFirst { e -> e.url == episode.url } 40 | Episode( 41 | name = episode.name, 42 | url = episode.url, 43 | lastPlayPosition = if (index != -1) localEpisodes[index].lastPlayPosition else 0L, 44 | isPlayed = index != -1 45 | ) 46 | } 47 | 48 | emit( 49 | Resource.Success( 50 | data = resource.data.copy( 51 | lastPosition = lastPosition.coerceAtLeast(0), 52 | episodes = episodeList 53 | ) 54 | ) 55 | ) 56 | } 57 | } 58 | } 59 | } 60 | } catch (e: Exception) { 61 | emit(Resource.Error(error = e)) 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/BackTopAppBar.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.automirrored.rounded.ArrowBack 6 | import androidx.compose.material3.ExperimentalMaterial3Api 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.IconButton 9 | import androidx.compose.material3.MaterialTheme 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.TopAppBar 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.text.style.TextOverflow 15 | import com.lanlinju.animius.R 16 | 17 | @OptIn(ExperimentalMaterial3Api::class) 18 | @Composable 19 | fun BackTopAppBar( 20 | title: String, 21 | actions: @Composable RowScope.() -> Unit = {}, 22 | onBackClick: () -> Unit 23 | ) { 24 | TopAppBar( 25 | title = { 26 | Text( 27 | text = title, 28 | style = MaterialTheme.typography.titleLarge, 29 | maxLines = 1, 30 | overflow = TextOverflow.Ellipsis 31 | ) 32 | }, 33 | navigationIcon = { 34 | IconButton(onClick = onBackClick) { 35 | Icon( 36 | imageVector = Icons.AutoMirrored.Rounded.ArrowBack, 37 | contentDescription = stringResource(id = R.string.back) 38 | ) 39 | } 40 | }, 41 | actions = actions 42 | ) 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/LoadingIndicator.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | 12 | @Composable 13 | fun LoadingIndicator(onLoading: () -> Unit = {}){ 14 | onLoading() 15 | Box( 16 | modifier = Modifier 17 | .fillMaxSize() 18 | .background(MaterialTheme.colorScheme.background), 19 | contentAlignment = Alignment.Center 20 | ){ 21 | CircularProgressIndicator() 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/PaginationStateHandler.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.paging.LoadState 5 | import androidx.paging.compose.LazyPagingItems 6 | 7 | @Composable 8 | fun PaginationStateHandler( 9 | paginationState: LazyPagingItems, 10 | loadingComponent: @Composable () -> Unit, 11 | errorComponent: @Composable ((Throwable) -> Unit)? = null 12 | ) { 13 | 14 | paginationState.apply { 15 | when { 16 | (loadState.refresh is LoadState.Loading) 17 | or (loadState.append is LoadState.Loading) 18 | or (loadState.prepend is LoadState.Loading) -> loadingComponent() 19 | 20 | (loadState.refresh is LoadState.Error) -> { 21 | errorComponent?.invoke((loadState.refresh as LoadState.Error).error) 22 | } 23 | (loadState.append is LoadState.Error) -> { 24 | errorComponent?.invoke((loadState.append as LoadState.Error).error) 25 | } 26 | (loadState.prepend is LoadState.Error) -> { 27 | errorComponent?.invoke((loadState.prepend as LoadState.Error).error) 28 | } 29 | } 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/PopupMenuListItem.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.material3.DropdownMenu 7 | import androidx.compose.material3.DropdownMenuItem 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.mutableStateOf 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.runtime.setValue 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 17 | import androidx.compose.ui.platform.LocalHapticFeedback 18 | import androidx.compose.ui.res.dimensionResource 19 | import androidx.compose.ui.unit.DpOffset 20 | import androidx.compose.ui.unit.dp 21 | import com.lanlinju.animius.R 22 | import com.lanlinju.animius.util.VIDEO_ASPECT_RATIO 23 | 24 | @OptIn(ExperimentalFoundationApi::class) 25 | @Composable 26 | fun PopupMenuListItem( 27 | menuText: String, 28 | onClick: () -> Unit, 29 | onMenuItemClick: () -> Unit, 30 | content: @Composable () -> Unit, 31 | ) { 32 | 33 | var expanded by remember { mutableStateOf(false) } 34 | val haptic = LocalHapticFeedback.current 35 | 36 | Box( 37 | modifier = Modifier.combinedClickable( 38 | onLongClick = { 39 | haptic.performHapticFeedback(HapticFeedbackType.LongPress) 40 | expanded = true 41 | }, 42 | onClick = onClick 43 | ) 44 | ) { 45 | 46 | content() 47 | 48 | DropdownMenu( 49 | expanded = expanded, 50 | onDismissRequest = { expanded = false }, 51 | offset = DpOffset( 52 | x = dimensionResource(id = R.dimen.image_cover_height) * VIDEO_ASPECT_RATIO + dimensionResource( 53 | id = R.dimen.small_padding 54 | ), 55 | y = 0.dp 56 | ), 57 | ) { 58 | 59 | DropdownMenuItem( 60 | text = { 61 | Text( 62 | text = menuText, 63 | style = MaterialTheme.typography.bodyMedium 64 | ) 65 | }, 66 | onClick = { 67 | expanded = false 68 | onMenuItemClick() 69 | } 70 | ) 71 | 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/ScrollableText.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.rememberScrollState 9 | import androidx.compose.foundation.verticalScroll 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Brush 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.res.dimensionResource 18 | import androidx.compose.ui.unit.Dp 19 | import com.lanlinju.animius.R 20 | 21 | @Composable 22 | fun ScrollableText( 23 | text: String, 24 | modifier: Modifier = Modifier, 25 | gradientSize: Dp = dimensionResource(R.dimen.edge_gradient_size), 26 | gradientColor: Color = MaterialTheme.colorScheme.background 27 | ) { 28 | Box(modifier) { 29 | Text( 30 | text = text, 31 | color = MaterialTheme.colorScheme.onBackground.copy( 32 | alpha = 0.75f 33 | ), 34 | style = MaterialTheme.typography.bodyMedium, 35 | modifier = Modifier 36 | .verticalScroll(rememberScrollState()) 37 | .padding(vertical = gradientSize) 38 | ) 39 | 40 | Box( 41 | modifier = Modifier 42 | .height(gradientSize) 43 | .fillMaxWidth() 44 | .align(Alignment.TopCenter) 45 | .background( 46 | Brush.verticalGradient( 47 | listOf( 48 | gradientColor, 49 | Color.Transparent 50 | ) 51 | ) 52 | ) 53 | ) 54 | 55 | Box( 56 | modifier = Modifier 57 | .height(gradientSize) 58 | .fillMaxWidth() 59 | .align(Alignment.BottomCenter) 60 | .background( 61 | Brush.verticalGradient( 62 | listOf( 63 | Color.Transparent, 64 | gradientColor 65 | ) 66 | ) 67 | ) 68 | ) 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/SourceBadge.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material3.Badge 6 | import androidx.compose.material3.ExperimentalMaterial3Api 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.text.TextStyle 14 | import androidx.compose.ui.unit.dp 15 | 16 | @OptIn(ExperimentalMaterial3Api::class) 17 | @Composable 18 | fun SourceBadge( 19 | modifier: Modifier = Modifier, 20 | text: String, 21 | isAlignmentStart: Boolean = true, 22 | style: TextStyle = MaterialTheme.typography.labelMedium, 23 | containerColor: Color = MaterialTheme.colorScheme.primaryContainer, 24 | contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, 25 | content: @Composable () -> Unit 26 | ) { 27 | Box(modifier) { 28 | content() 29 | 30 | Badge( 31 | modifier = Modifier 32 | .padding(4.dp) 33 | .align(if (isAlignmentStart) Alignment.TopStart else Alignment.TopEnd), 34 | containerColor = containerColor, 35 | contentColor = contentColor 36 | ) { 37 | Text( 38 | modifier = Modifier.padding(2.dp), 39 | text = text, 40 | style = style, 41 | ) 42 | } 43 | 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/StateHandler.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.lanlinju.animius.util.Resource 5 | 6 | @Composable 7 | fun StateHandler( 8 | state: Resource, 9 | onLoading: @Composable (Resource) -> Unit, 10 | onFailure: @Composable (Resource) -> Unit, 11 | onSuccess: @Composable (Resource) -> Unit 12 | ){ 13 | 14 | if(state is Resource.Loading){ 15 | onLoading(state) 16 | } 17 | if(state is Resource.Error){ 18 | onFailure(state) 19 | } 20 | onSuccess(state) 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/TranslucentStatusBarLayout.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.compose.foundation.ScrollState 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.WindowInsets 6 | import androidx.compose.foundation.layout.statusBars 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.draw.drawWithContent 11 | import androidx.compose.ui.geometry.Size 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.platform.LocalDensity 14 | import androidx.compose.ui.res.dimensionResource 15 | import androidx.compose.ui.unit.Dp 16 | import com.lanlinju.animius.R 17 | 18 | @Composable 19 | fun TranslucentStatusBarLayout( 20 | scrollState: ScrollState, 21 | distanceUntilAnimated: Dp = dimensionResource(R.dimen.banner_height), 22 | modifier: Modifier = Modifier, 23 | targetAlpha: Float = 0.75f, 24 | targetColor: Color = MaterialTheme.colorScheme.background, 25 | content: @Composable () -> Unit 26 | ) { 27 | // TODO: Can this be a modifier? 28 | val distanceUntilAnimatedPx = with(LocalDensity.current) { distanceUntilAnimated.toPx() } 29 | val statusBarInsets = WindowInsets.statusBars 30 | Box( 31 | Modifier 32 | .drawWithContent { 33 | drawContent() 34 | drawRect( 35 | color = targetColor.copy( 36 | alpha = targetAlpha * (scrollState.value.toFloat() / distanceUntilAnimatedPx) 37 | .coerceIn(0f..1f) 38 | ), 39 | size = Size( 40 | width = size.width, 41 | height = statusBarInsets 42 | .getTop(this) 43 | .toFloat() 44 | ) 45 | ) 46 | } 47 | .then(modifier) 48 | ) { 49 | content() 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/component/WarningMessage.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.component 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.rounded.Info 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.OutlinedButton 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.unit.dp 16 | import com.lanlinju.animius.R 17 | 18 | @Composable 19 | fun WarningMessage( 20 | @StringRes textId: Int, 21 | extraText: String = "", 22 | onRetryClick: (() -> Unit)? = null, 23 | ) { 24 | Column( 25 | modifier = Modifier.fillMaxSize(), 26 | horizontalAlignment = Alignment.CenterHorizontally, 27 | verticalArrangement = Arrangement.Center 28 | ) { 29 | Icon( 30 | imageVector = Icons.Rounded.Info, 31 | tint = MaterialTheme.colorScheme.onSurface, 32 | contentDescription = "" 33 | ) 34 | Spacer(modifier = Modifier.padding(vertical = 8.dp)) 35 | Text( 36 | text = stringResource(id = textId), 37 | color = MaterialTheme.colorScheme.onSurface, 38 | style = MaterialTheme.typography.bodyMedium 39 | ) 40 | Text( 41 | text = extraText, 42 | color = MaterialTheme.colorScheme.onSurface, 43 | style = MaterialTheme.typography.bodyMedium 44 | ) 45 | if (onRetryClick != null) { 46 | OutlinedButton(onClick = onRetryClick) { 47 | Text(text = stringResource(id = R.string.lbl_retry)) 48 | } 49 | } 50 | 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/screen/crash/CrashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.screen.crash 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.enableEdgeToEdge 8 | import com.lanlinju.animius.MainActivity 9 | import com.lanlinju.animius.presentation.theme.AnimeTheme 10 | 11 | class CrashActivity : ComponentActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | enableEdgeToEdge() 15 | 16 | // 获取传递的崩溃日志 17 | val crashLog = intent.getStringExtra("crash_log") ?: "No crash log available" 18 | 19 | setContent { 20 | AnimeTheme { 21 | CrashScreen( 22 | crashLog = crashLog, 23 | onRestartClick = { 24 | finishAffinity() 25 | startActivity(Intent(this@CrashActivity, MainActivity::class.java)) 26 | }, 27 | ) 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/screen/download/DownloadViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.screen.download 2 | 3 | import android.content.Context 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import com.lanlinju.animius.domain.model.Download 7 | import com.lanlinju.animius.domain.repository.RoomRepository 8 | import com.lanlinju.animius.util.Resource 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.StateFlow 12 | import kotlinx.coroutines.launch 13 | import java.io.File 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class DownloadViewModel @Inject constructor( 18 | private val roomRepository: RoomRepository 19 | ) : ViewModel() { 20 | private val _downloadList: MutableStateFlow>> = 21 | MutableStateFlow(value = Resource.Loading) 22 | 23 | val downloadList: StateFlow>> 24 | get() = _downloadList 25 | 26 | init { 27 | getAllDownloads() 28 | } 29 | 30 | private fun getAllDownloads() { 31 | viewModelScope.launch { 32 | roomRepository.getDownloads().collect { 33 | _downloadList.value = Resource.Success(it) 34 | } 35 | } 36 | } 37 | 38 | fun deleteDownload(detailUrl: String, title: String, context: Context) { 39 | viewModelScope.launch { 40 | context.getExternalFilesDir("download/${title}")?.path?.let {dir-> 41 | File(dir).delete() 42 | } 43 | roomRepository.deleteDownload(detailUrl) 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.screen.downloaddetail 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import androidx.navigation.toRoute 7 | import com.lanlinju.animius.domain.model.DownloadDetail 8 | import com.lanlinju.animius.domain.repository.RoomRepository 9 | import com.lanlinju.animius.presentation.navigation.Screen 10 | import com.lanlinju.animius.util.Resource 11 | import com.lanlinju.download.core.DownloadTask 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class DownloadDetailViewModel @Inject constructor( 20 | savedStateHandle: SavedStateHandle, 21 | private val roomRepository: RoomRepository 22 | ) : ViewModel() { 23 | 24 | private val _downloadDetailsState: MutableStateFlow>> = 25 | MutableStateFlow(value = Resource.Loading) 26 | val downloadDetailsState: StateFlow>> 27 | get() = _downloadDetailsState 28 | 29 | private val _title: MutableStateFlow = MutableStateFlow("") 30 | val title: StateFlow get() = _title 31 | 32 | 33 | init { 34 | savedStateHandle.toRoute().let { 35 | _title.value = it.title 36 | getDownloadDetails(it.detailUrl) 37 | } 38 | } 39 | 40 | // TODO:使用传递downloadId获取downloadDetail 41 | private fun getDownloadDetails(detailUrl: String) { 42 | viewModelScope.launch { 43 | roomRepository.getDownloadDetails(detailUrl).collect { 44 | _downloadDetailsState.value = Resource.Success(it) 45 | } 46 | } 47 | } 48 | 49 | fun updateDownloadDetail(downloadDetail: DownloadDetail, downloadTask: DownloadTask) { 50 | viewModelScope.launch { 51 | val d = downloadDetail.copy( 52 | downloadSize = downloadTask.getProgress().downloadSize, 53 | totalSize = downloadTask.getProgress().totalSize, 54 | fileSize = downloadTask.file()?.length() ?: 0 55 | ) 56 | roomRepository.updateDownloadDetail(d) 57 | } 58 | } 59 | 60 | fun deleteDownloadDetail(downloadUrl: String, deleteFile:() -> Unit) { 61 | viewModelScope.launch { 62 | deleteFile() 63 | roomRepository.deleteDownloadDetail(downloadUrl) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/screen/favourite/FavouriteViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.screen.favourite 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.lanlinju.animius.domain.model.Favourite 6 | import com.lanlinju.animius.domain.repository.RoomRepository 7 | import com.lanlinju.animius.util.Resource 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.launch 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class FavouriteViewModel @Inject constructor( 16 | private val roomRepository: RoomRepository 17 | ) : ViewModel() { 18 | private val _favouriteList: MutableStateFlow>> = 19 | MutableStateFlow(value = Resource.Loading) 20 | val favouriteList: StateFlow>> 21 | get() = _favouriteList 22 | 23 | init { 24 | getAllFavourites() 25 | } 26 | 27 | private fun getAllFavourites() { 28 | viewModelScope.launch { 29 | roomRepository.getFavourites().collect { favourites -> 30 | _favouriteList.value = Resource.Success(favourites) 31 | } 32 | } 33 | } 34 | 35 | fun removeFavourite(detailUrl: String) { 36 | viewModelScope.launch { 37 | roomRepository.removeFavourite(detailUrl) 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/screen/history/HistoryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.screen.history 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.lanlinju.animius.domain.model.History 6 | import com.lanlinju.animius.domain.repository.RoomRepository 7 | import com.lanlinju.animius.util.Resource 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.launch 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class HistoryViewModel @Inject constructor( 16 | private val roomRepository: RoomRepository 17 | ) : ViewModel() { 18 | private val _historyList: MutableStateFlow>> = 19 | MutableStateFlow(value = Resource.Loading) 20 | 21 | val historyList: StateFlow>> 22 | get() = _historyList 23 | 24 | init { 25 | getAllHistories() 26 | } 27 | 28 | private fun getAllHistories() { 29 | viewModelScope.launch { 30 | roomRepository.getHistories().collect { 31 | _historyList.value = Resource.Success(it) 32 | } 33 | } 34 | } 35 | 36 | /* 37 | fun updateHistoryDate(detailUrl: String) { 38 | viewModelScope.launch { 39 | roomRepository.updateHistoryDate(detailUrl) 40 | } 41 | }*/ 42 | 43 | fun deleteHistory(detailUrl: String) { 44 | viewModelScope.launch { 45 | roomRepository.deleteHistory(detailUrl) 46 | } 47 | } 48 | 49 | fun deleteAllHistories() { 50 | viewModelScope.launch { 51 | roomRepository.deleteAllHistories() 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/screen/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.screen.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.lanlinju.animius.domain.model.Home 6 | import com.lanlinju.animius.domain.repository.AnimeRepository 7 | import com.lanlinju.animius.util.Resource 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.launch 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class HomeViewModel @Inject constructor( 16 | private val repository: AnimeRepository 17 | ) : ViewModel() { 18 | private val _homeDataList: MutableStateFlow>> = 19 | MutableStateFlow(value = Resource.Loading) 20 | val homeDataList: StateFlow>> 21 | get() = _homeDataList 22 | 23 | init { 24 | getHomeData() 25 | } 26 | 27 | private fun getHomeData() { 28 | viewModelScope.launch { 29 | _homeDataList.value = repository.getHomeData() 30 | } 31 | } 32 | 33 | fun refresh() { 34 | _homeDataList.value = Resource.Loading 35 | getHomeData() 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.screen.search 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.PagingData 6 | import androidx.paging.cachedIn 7 | import com.lanlinju.animius.domain.model.Anime 8 | import com.lanlinju.animius.domain.repository.AnimeRepository 9 | import com.lanlinju.animius.util.SourceHolder 10 | import com.lanlinju.animius.util.SourceMode 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.distinctUntilChanged 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class SearchViewModel @Inject constructor( 20 | private val repository: AnimeRepository 21 | ) : ViewModel() { 22 | private val _animesState: MutableStateFlow> = 23 | MutableStateFlow(value = PagingData.empty()) 24 | val animesState: StateFlow> 25 | get() = _animesState 26 | 27 | private val _query: MutableStateFlow = MutableStateFlow(value = "") 28 | val query: StateFlow 29 | get() = _query 30 | 31 | // 只在第一次进入时请求焦点 32 | var hasFocusRequest = false 33 | 34 | /** 35 | * 用于标识使用当前动漫源搜索数据 36 | * 37 | * Note: 在Compose 组合函数中,从上一个界面返当前界面时,[remember]保存的数据状态会丢失。 38 | */ 39 | var currentSourceMode = SourceHolder.currentSourceMode 40 | 41 | fun onSearch(query: String, mode: SourceMode) { 42 | getSearchData(query, mode) 43 | } 44 | 45 | fun clearSearchQuery() { 46 | _query.value = "" 47 | } 48 | 49 | fun onQuery(query: String) { 50 | _query.value = query 51 | } 52 | 53 | fun getSearchData(query: String, mode: SourceMode) { 54 | if (query.isEmpty()) return 55 | 56 | viewModelScope.launch { 57 | repository.getSearchData(query, mode) 58 | .distinctUntilChanged() 59 | .cachedIn(viewModelScope) 60 | .collect { 61 | _animesState.value = it 62 | } 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/presentation/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.presentation.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/Const.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | const val CROSSFADE_DURATION = 500 4 | const val VIDEO_ASPECT_RATIO = 1.778f 5 | 6 | const val LOW_CONTENT_ALPHA= 0.35f 7 | 8 | val TABS = listOf("一", "二", "三", "四", "五", "六", "日") 9 | 10 | const val CRASH_LOG_FILE = "anime_crash_logs.txt" 11 | 12 | const val GITHUB_ADDRESS = "https://app.laqoo.eu.org" 13 | const val CHECK_UPDATE_ADDRESS = "https://api.github.com/repos/laqoome/laqoo/releases/latest" 14 | const val GITHUB_RELEASE_ADDRESS = "https://app.laqoo.eu.org" 15 | 16 | const val ANIME_DATABASE = "anime_database.db" 17 | const val FAVOURITE_TABLE = "favourite_table" 18 | const val HISTORY_TABLE = "history_table" 19 | const val EPISODE_TABLE = "episode_table" 20 | const val DOWNLOAD_TABLE = "download_table" 21 | const val DOWNLOAD_DETAIL_TABLE = "download_detail_table" 22 | 23 | const val SEARCH_PAGE_SIZE = 10 24 | 25 | const val KEY_DOWNLOAD_UPDATE_URL = "downloadUpdateUrl" -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/HttpClientExt.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | 6 | suspend inline fun safeCall( 7 | crossinline apiCall: suspend () -> T 8 | ): Result { 9 | return withContext(Dispatchers.IO) { 10 | try { 11 | Result.Success(apiCall()) 12 | } catch (e: Exception) { 13 | Result.Error(e) 14 | } 15 | } 16 | } 17 | 18 | suspend fun invokeApi( 19 | apiCall: suspend () -> T 20 | ): Resource { 21 | return withContext(Dispatchers.IO) { 22 | try { 23 | Resource.Success(apiCall.invoke()) 24 | } catch (throwable: Throwable) { 25 | Resource.Error(error = throwable) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/ModifierExt.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | import androidx.compose.foundation.ScrollState 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.graphics.graphicsLayer 6 | 7 | fun Modifier.bannerParallax(scrollState: ScrollState) = graphicsLayer { 8 | translationY = 0.7f * scrollState.value 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/Resource.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | sealed class Resource( 4 | val data: T? = null, 5 | val error: Throwable? = null 6 | ) { 7 | class Success(data: T) : Resource(data = data) 8 | object Loading : Resource() 9 | class Error(error: Throwable? = null) : Resource(error = error) 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/Result.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | sealed interface Result { 4 | data class Success(val data: T) : Result 5 | data class Error(val error: Throwable) : Result 6 | } 7 | 8 | inline fun Result.map(map: (T) -> R): Result { 9 | return when (this) { 10 | is Result.Error -> Result.Error(error) 11 | is Result.Success -> Result.Success(map(data)) 12 | } 13 | } 14 | 15 | inline fun Result.onSuccess(action: (T) -> Unit): Result { 16 | return when (this) { 17 | is Result.Error -> this 18 | is Result.Success -> { 19 | action(data) 20 | this 21 | } 22 | } 23 | } 24 | 25 | inline fun Result.onError(action: (Throwable) -> Unit): Result { 26 | return when (this) { 27 | is Result.Error -> { 28 | action(error) 29 | this 30 | } 31 | 32 | is Result.Success -> this 33 | } 34 | } 35 | 36 | fun Result.asEmptyDataResult(): EmptyResult { 37 | return map { } 38 | } 39 | 40 | typealias EmptyResult = Result -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/SettingsPreferences.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.core.content.edit 5 | import com.lanlinju.animius.R 6 | import com.lanlinju.animius.application.AnimeApplication 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | 10 | object SettingsPreferences { 11 | enum class ThemeMode(@StringRes val resId: Int) { 12 | LIGHT(R.string.light), DARK(R.string.dark), SYSTEM(R.string.system), 13 | } 14 | 15 | private val preferences = AnimeApplication.getInstance().preferences 16 | 17 | private val _themeMode = MutableStateFlow(preferences.getEnum(KEY_THEME_MODE, ThemeMode.SYSTEM)) 18 | val themeMode = _themeMode.asStateFlow() 19 | 20 | private val _customColor = 21 | MutableStateFlow(preferences.getInt(KEY_CUSTOM_COLOR, catpucchinLatte.first())) 22 | val customColor = _customColor.asStateFlow() 23 | 24 | private val _dynamicColor = MutableStateFlow(preferences.getBoolean(KEY_DYNAMIC_COLOR, false)) 25 | val dynamicColor = _dynamicColor.asStateFlow() 26 | 27 | 28 | fun changeThemeMode(themeMode: ThemeMode) { 29 | _themeMode.value = themeMode 30 | preferences.edit { putEnum(KEY_THEME_MODE, themeMode) } 31 | } 32 | 33 | fun changeCustomColor(customColor: Int) { 34 | _customColor.value = customColor 35 | preferences.edit { putInt(KEY_CUSTOM_COLOR, customColor) } 36 | } 37 | 38 | fun applyImageColor(imageColor: Int) { 39 | _customColor.value = imageColor 40 | } 41 | 42 | fun changeDynamicColor(dynamicTheme: Boolean) { 43 | _dynamicColor.value = dynamicTheme 44 | preferences.edit { putBoolean(KEY_DYNAMIC_COLOR, dynamicTheme) } 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/SourceHolder.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | import com.lanlinju.animius.application.AnimeApplication 4 | import com.lanlinju.animius.data.remote.parse.AgedmSource 5 | import com.lanlinju.animius.data.remote.parse.AnfunsSource 6 | import com.lanlinju.animius.data.remote.parse.AnimeSource 7 | import com.lanlinju.animius.data.remote.parse.CycanimeSource 8 | import com.lanlinju.animius.data.remote.parse.GirigiriSource 9 | import com.lanlinju.animius.data.remote.parse.GogoanimeSource 10 | import com.lanlinju.animius.data.remote.parse.MxdmSource 11 | import com.lanlinju.animius.data.remote.parse.NyafunSource 12 | import com.lanlinju.animius.data.remote.parse.SilisiliSource 13 | import com.lanlinju.animius.data.remote.parse.YhdmSource 14 | 15 | object SourceHolder { 16 | private lateinit var _currentSource: AnimeSource 17 | private lateinit var _currentSourceMode: SourceMode 18 | 19 | /** 20 | * 默认动漫源 21 | */ 22 | val DEFAULT_ANIME_SOURCE = SourceMode.Silisili 23 | 24 | val currentSource: AnimeSource 25 | get() = _currentSource 26 | 27 | val currentSourceMode: SourceMode 28 | get() = _currentSourceMode 29 | 30 | var isSourceChanged = false 31 | 32 | init { 33 | val preferences = AnimeApplication.getInstance().preferences 34 | initDefaultSource(preferences.getEnum(KEY_SOURCE_MODE, DEFAULT_ANIME_SOURCE)) 35 | } 36 | 37 | /** 38 | * 初始化加载默认的数据源,切换数据源请用方法[SourceHolder].switchSource() 39 | */ 40 | private fun initDefaultSource(mode: SourceMode) { 41 | _currentSource = getSource(mode) 42 | _currentSourceMode = mode 43 | _currentSource.onEnter() 44 | } 45 | 46 | /** 47 | *切换数据源 48 | */ 49 | fun switchSource(mode: SourceMode) { 50 | _currentSource.onExit() 51 | 52 | _currentSource = getSource(mode) 53 | _currentSourceMode = mode 54 | 55 | _currentSource.onEnter() 56 | } 57 | 58 | /** 59 | * 根据[SourceMode]获取对应的[AnimeSource]数据源 60 | * */ 61 | fun getSource(mode: SourceMode): AnimeSource { 62 | return when (mode) { 63 | SourceMode.Yhdm -> YhdmSource 64 | SourceMode.Silisili -> SilisiliSource 65 | SourceMode.Mxdm -> MxdmSource 66 | SourceMode.Agedm -> AgedmSource 67 | SourceMode.Anfuns -> AnfunsSource 68 | SourceMode.Girigiri -> GirigiriSource 69 | SourceMode.Nyafun -> NyafunSource 70 | SourceMode.Cycanime -> CycanimeSource 71 | SourceMode.Gogoanime -> GogoanimeSource 72 | } 73 | } 74 | } 75 | 76 | enum class SourceMode { 77 | Silisili, 78 | Mxdm, 79 | Girigiri, 80 | Agedm, 81 | Cycanime, 82 | Anfuns, 83 | Gogoanime, 84 | Yhdm, 85 | Nyafun 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/util/ThemeUtil.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.util 2 | 3 | import android.annotation.SuppressLint 4 | import android.graphics.Bitmap 5 | import androidx.compose.material3.ColorScheme 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.palette.graphics.Palette 8 | import com.google.android.material.color.utilities.DynamicScheme 9 | import com.google.android.material.color.utilities.Hct 10 | import com.google.android.material.color.utilities.SchemeVibrant 11 | 12 | // 参考 https://github.com/you-apps/VibeYou/blob/c5c7eb3e9a2228b715689d9524cdc98db139287d/app/src/main/java/app/suhasdissa/vibeyou/utils/ThemeUtil.kt#L10 13 | @SuppressLint("RestrictedApi") 14 | fun getSchemeFromSeed(color: Int, dark: Boolean): ColorScheme { 15 | val hct = Hct.fromInt(color) 16 | return SchemeVibrant(hct, dark, 0.0).toColorScheme() 17 | } 18 | 19 | val catpucchinLatte = arrayOf( 20 | android.graphics.Color.rgb(248, 176, 96), 21 | android.graphics.Color.rgb(220, 138, 120), 22 | android.graphics.Color.rgb(221, 120, 120), 23 | android.graphics.Color.rgb(234, 118, 203), 24 | android.graphics.Color.rgb(136, 77, 210), 25 | android.graphics.Color.rgb(210, 65, 57), 26 | android.graphics.Color.rgb(230, 69, 83), 27 | android.graphics.Color.rgb(254, 100, 11), 28 | android.graphics.Color.rgb(223, 142, 29), 29 | android.graphics.Color.rgb(64, 160, 43), 30 | android.graphics.Color.rgb(23, 146, 153), 31 | android.graphics.Color.rgb(4, 165, 229), 32 | android.graphics.Color.rgb(32, 159, 181), 33 | android.graphics.Color.rgb(30, 102, 245), 34 | android.graphics.Color.rgb(114, 135, 253), 35 | ) 36 | 37 | @SuppressLint("RestrictedApi") 38 | fun DynamicScheme.toColorScheme() = ColorScheme( 39 | primary = Color(primary), 40 | onPrimary = Color(onPrimary), 41 | primaryContainer = Color(primaryContainer), 42 | onPrimaryContainer = Color(onPrimaryContainer), 43 | inversePrimary = Color(inversePrimary), 44 | secondary = Color(secondary), 45 | onSecondary = Color(onSecondary), 46 | secondaryContainer = Color(secondaryContainer), 47 | onSecondaryContainer = Color(onSecondaryContainer), 48 | tertiary = Color(tertiary), 49 | onTertiary = Color(onTertiary), 50 | tertiaryContainer = Color(tertiaryContainer), 51 | onTertiaryContainer = Color(onTertiaryContainer), 52 | background = Color(background), 53 | onBackground = Color(onBackground), 54 | surface = Color(surface), 55 | onSurface = Color(onSurface), 56 | surfaceVariant = Color(surfaceVariant), 57 | onSurfaceVariant = Color(onSurfaceVariant), 58 | surfaceTint = Color(surfaceTint), 59 | inverseSurface = Color(inverseSurface), 60 | inverseOnSurface = Color(inverseOnSurface), 61 | error = Color(error), 62 | onError = Color(onError), 63 | errorContainer = Color(errorContainer), 64 | onErrorContainer = Color(onErrorContainer), 65 | outline = Color(outline), 66 | outlineVariant = Color(outlineVariant), 67 | scrim = Color(scrim), 68 | surfaceBright = Color(surfaceBright), 69 | surfaceDim = Color(surfaceDim), 70 | surfaceContainer = Color(surfaceContainer), 71 | surfaceContainerHigh = Color(surfaceContainerHigh), 72 | surfaceContainerHighest = Color(surfaceContainerHighest), 73 | surfaceContainerLow = Color(surfaceContainerLow), 74 | surfaceContainerLowest = Color(surfaceContainerLowest), 75 | ) 76 | 77 | suspend fun dynamicColorOf(bitmap: Bitmap): Color? { 78 | val palette = Palette 79 | .from(bitmap) 80 | .maximumColorCount(8) 81 | .generate() 82 | 83 | val dominantSwatch = palette.dominantSwatch ?: return null 84 | 85 | return Color(dominantSwatch.rgb) 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lanlinju/animius/work/UpdateWorker.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.work 2 | 3 | import android.content.Context 4 | import androidx.work.CoroutineWorker 5 | import androidx.work.WorkerParameters 6 | import com.lanlinju.animius.util.KEY_DOWNLOAD_UPDATE_URL 7 | import com.lanlinju.animius.util.installApk 8 | import com.lanlinju.animius.util.log 9 | import com.lanlinju.download.download 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.withContext 12 | import java.io.File 13 | 14 | class UpdateWorker(appContext: Context, workerParams: WorkerParameters) : 15 | CoroutineWorker(appContext, workerParams) { 16 | 17 | override suspend fun doWork(): Result { 18 | val url = inputData.getString(KEY_DOWNLOAD_UPDATE_URL) ?: return Result.failure() 19 | 20 | return withContext(Dispatchers.IO) { 21 | try { 22 | val savePath = applicationContext.getExternalFilesDir("apk/")!!.path 23 | val file = File(savePath, "base.apk") 24 | 25 | if (file.exists()) file.delete() 26 | 27 | val downloadTask = download(url = url, saveName = "base.apk", savePath = savePath) 28 | downloadTask.suspendStart() 29 | 30 | if (downloadTask.isSucceed()) { 31 | applicationContext.installApk(file) 32 | Result.success() 33 | } else { 34 | Result.failure() 35 | } 36 | } catch (e: Exception) { 37 | e.message?.log("UpdateWorker: ") 38 | Result.failure() 39 | } 40 | } 41 | 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/drawable/background.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/home.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_brightness.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_content_paste.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_domain.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/drawable/ic_github.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_history.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_volume_mute.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_volume_up.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/manga.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 14 | 17 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/rslash.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/search.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8dp 4 | 16dp 5 | 24dp 6 | 7 | 16dp 8 | 28dp 9 | 10 | 18dp 11 | 16dp 12 | 10dp 13 | 14 | 65dp 15 | 16 | 168dp 17 | 140dp 18 | 120dp 19 | 200dp 20 | 96dp 21 | 96dp 22 | 30dp 23 | 24 | 8dp 25 | 26 | 80dp 27 | 8dp 28 | 29 | 80dp 30 | 31 | 56dp 32 | 33 | 160dp 34 | 35 | 56dp 36 | 96dp 37 | 10dp 38 | 36dp 39 | 32dp 40 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FDB265 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/xml/file_paths.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 10 | 11 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/test/java/com/lanlinju/animius/dandanplay/MoviePatternTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.dandanplay 2 | 3 | import org.junit.Assert.assertFalse 4 | import org.junit.Assert.assertTrue 5 | import org.junit.Test 6 | 7 | class MoviePatternTest { 8 | private val moviePattern = Regex("全集|HD|正片") 9 | 10 | @Test 11 | fun testMoviePattern_Matches() { 12 | // 测试匹配“全集” 13 | assertTrue(moviePattern.containsMatchIn("全集")) 14 | 15 | // 测试匹配“HD” 16 | assertTrue(moviePattern.containsMatchIn("高清HD")) 17 | 18 | // 测试匹配“正片” 19 | assertTrue(moviePattern.containsMatchIn("正片")) 20 | } 21 | 22 | @Test 23 | fun testMoviePattern_DoesNotMatch() { 24 | // 测试不匹配的字符串 25 | assertFalse(moviePattern.containsMatchIn("第01集")) 26 | assertFalse(moviePattern.containsMatchIn("预告片")) 27 | assertFalse(moviePattern.containsMatchIn("其他")) 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/test/java/com/lanlinju/animius/dandanplay/SignatureGeneratorTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.dandanplay 2 | 3 | import java.security.MessageDigest 4 | import java.security.NoSuchAlgorithmException 5 | import java.util.Base64 6 | import java.util.Date 7 | 8 | object SignatureGeneratorTest { 9 | @JvmStatic 10 | fun main(args: Array) { 11 | val appId = "your_app_id" 12 | val appSecret = "your_app_secret" 13 | val timestamp = Date().time / 1000 14 | val path = "/api/v2/comment/123450001" 15 | val signature = generateSignature(appId, timestamp, path, appSecret) 16 | println("X-AppId: $appId") 17 | println("X-Signature: $signature") 18 | println("X-Timestamp: $timestamp") 19 | } 20 | 21 | private fun generateSignature( 22 | appId: String, 23 | timestamp: Long, 24 | path: String, 25 | appSecret: String 26 | ): String? { 27 | val data = appId + timestamp + path + appSecret 28 | return try { 29 | val digest = MessageDigest.getInstance("SHA-256") 30 | val hash = digest.digest(data.toByteArray()) 31 | Base64.getEncoder().encodeToString(hash) 32 | } catch (e: NoSuchAlgorithmException) { 33 | e.printStackTrace(); null 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/test/java/com/lanlinju/animius/serializer/SerializerTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.serializer 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.encodeToString 5 | import kotlinx.serialization.json.Json 6 | import org.junit.Assert.assertEquals 7 | import org.junit.Test 8 | 9 | class SerializerTest { 10 | 11 | @Test 12 | fun testEncode() { 13 | // 创建 Person 对象 14 | val person = Person(name = "Tom", age = 20) 15 | 16 | val json = Json { 17 | ignoreUnknownKeys = true 18 | encodeDefaults = true 19 | } 20 | // 将 Person 对象序列化为 JSON 字符串 21 | val jsonString = json.encodeToString(person) 22 | 23 | // 验证序列化结果 24 | assertEquals("""{"name":"Tom","age":20}""", jsonString) 25 | } 26 | 27 | @Test 28 | fun testDecode() { 29 | // JSON 字符串 30 | val jsonString = """{"name":"Tom","age":20}""" 31 | 32 | // 将 JSON 字符串反序列化为 Person 对象 33 | val person = Json.decodeFromString(jsonString) 34 | 35 | // 验证反序列化结果 36 | assertEquals("Tom", person.name) 37 | assertEquals(20, person.age) 38 | } 39 | 40 | @Serializable 41 | data class Person( 42 | val name: String = "Tom", 43 | val age: Int = 20 44 | ) 45 | } 46 | 47 | -------------------------------------------------------------------------------- /app/src/test/java/com/lanlinju/animius/source/YhdmSourceTest.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.animius.source 2 | 3 | import com.lanlinju.animius.data.remote.api.AnimeApiImpl 4 | import com.lanlinju.animius.data.remote.parse.YhdmSource.baseUrl 5 | import com.lanlinju.animius.util.DownloadManager 6 | import com.lanlinju.animius.util.SourceMode 7 | import kotlinx.coroutines.runBlocking 8 | import org.junit.Assert.assertEquals 9 | import org.junit.Test 10 | import java.time.LocalDate 11 | 12 | /** 13 | * Example local unit test, which will execute on the development machine (host). 14 | * 15 | * See [testing documentation](http://d.android.com/tools/testing). 16 | */ 17 | class YhdmSourceTest { 18 | 19 | val api = AnimeApiImpl() 20 | 21 | @Test 22 | fun addition_isCorrect() { 23 | assertEquals(4, 2 + 2) 24 | } 25 | 26 | @Test 27 | fun test_network() { 28 | runBlocking { 29 | val html = DownloadManager.getHtml(baseUrl) 30 | println(html) 31 | } 32 | } 33 | 34 | @Test 35 | fun test_detail() { 36 | runBlocking { 37 | println(api.getAnimeDetail("5042.html", SourceMode.Yhdm)) 38 | } 39 | } 40 | 41 | 42 | @Test 43 | fun test_search() { 44 | runBlocking { 45 | val query = "海贼王" 46 | println(api.getSearchData(query, 1, SourceMode.Yhdm)) 47 | } 48 | } 49 | 50 | @Test 51 | fun test_week() { 52 | runBlocking { 53 | println(api.getWeekDate()) 54 | } 55 | } 56 | 57 | @Test 58 | fun test_dayOfWeek() { 59 | // 获取当前日期 60 | val currentDate = LocalDate.now() 61 | 62 | // 获取今天是星期几的数字表示形式(1 表示星期一,2 表示星期二,以此类推) 63 | val dayOfWeekNumber: Int = currentDate.dayOfWeek.value 64 | 65 | // 输出星期几的数字表示形式 66 | println("今天是星期:$dayOfWeekNumber") 67 | 68 | } 69 | } -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | alias(libs.plugins.android.application) apply false 4 | alias(libs.plugins.jetbrains.kotlin.android) apply false 5 | alias(libs.plugins.android.library) apply false 6 | alias(libs.plugins.ksp) apply false 7 | alias(libs.plugins.hilt) apply false 8 | alias(libs.plugins.compose.compiler) apply false 9 | alias(libs.plugins.kotlinx.serialization) apply false 10 | } -------------------------------------------------------------------------------- /danmaku/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /danmaku/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "com.anime.danmaku" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | minSdk = 26 13 | 14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles("consumer-rules.pro") 16 | } 17 | 18 | buildTypes { 19 | release { 20 | isMinifyEnabled = false 21 | proguardFiles( 22 | getDefaultProguardFile("proguard-android-optimize.txt"), 23 | "proguard-rules.pro" 24 | ) 25 | } 26 | } 27 | buildFeatures { 28 | compose = true 29 | } 30 | compileOptions { 31 | sourceCompatibility = JavaVersion.VERSION_17 32 | targetCompatibility = JavaVersion.VERSION_17 33 | } 34 | kotlinOptions { 35 | jvmTarget = "17" 36 | } 37 | } 38 | 39 | dependencies { 40 | implementation(libs.androidx.ui) 41 | implementation(libs.androidx.material3) 42 | implementation(libs.androidx.ui.tooling.preview) 43 | implementation(platform(libs.androidx.compose.bom)) 44 | testImplementation(libs.junit) 45 | androidTestImplementation(libs.androidx.junit) 46 | androidTestImplementation(libs.androidx.espresso.core) 47 | androidTestImplementation(platform(libs.androidx.compose.bom)) 48 | androidTestImplementation(libs.androidx.ui.test.junit4) 49 | debugImplementation(libs.androidx.ui.tooling) 50 | debugImplementation(libs.androidx.ui.test.manifest) 51 | } -------------------------------------------------------------------------------- /danmaku/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/danmaku/consumer-rules.pro -------------------------------------------------------------------------------- /danmaku/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /danmaku/src/androidTest/java/com/anime/danmaku/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.anime.danmaku 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.anime.danmaku.test", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /danmaku/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /danmaku/src/main/java/com/anime/danmaku/api/Danmaku.kt: -------------------------------------------------------------------------------- 1 | package com.anime.danmaku.api 2 | 3 | import androidx.compose.runtime.Immutable 4 | 5 | @Immutable 6 | class DanmakuPresentation( 7 | val danmaku: Danmaku, 8 | val isSelf: Boolean, 9 | ) { 10 | val id get() = danmaku.id 11 | } 12 | 13 | @Immutable 14 | data class Danmaku( 15 | val id: String, 16 | val providerId: String, 17 | val playTimeMillis: Long, 18 | val senderId: String, 19 | val location: DanmakuLocation, 20 | val text: String, 21 | val color: Int, // RGB 22 | ) { 23 | override fun toString(): String { 24 | return "Danmaku(id='$id', providerId='$providerId', playTimeMillis=$playTimeMillis, senderId='$senderId', location=$location, text='$text', color=$color)" 25 | } 26 | } 27 | 28 | enum class DanmakuLocation { 29 | TOP, 30 | BOTTOM, 31 | NORMAL, 32 | } -------------------------------------------------------------------------------- /danmaku/src/main/java/com/anime/danmaku/ui/DanmakuTrack.kt: -------------------------------------------------------------------------------- 1 | package com.anime.danmaku.ui 2 | 3 | import androidx.compose.runtime.Stable 4 | 5 | /** 6 | * 弹幕轨道, 支持放置已知长度的弹幕. 7 | * 8 | * 这意味着[已经知道长度的弹幕][SizeSpecifiedDanmaku]一定可以计算是否可以放置到此轨道上. 9 | */ 10 | @Stable 11 | interface DanmakuTrack { 12 | /** 13 | * 放置弹幕到此轨道 14 | * 15 | * @return 返回已经放置的弹幕 16 | */ 17 | fun place(danmaku: T): D 18 | 19 | /** 20 | * 检测这条弹幕是否可以放置到此轨道中. 21 | * 22 | * @return 可放置返回`true` 23 | */ 24 | fun canPlace(danmaku: T): Boolean 25 | 26 | /** 27 | * 尝试放置弹幕 28 | * 29 | * @return 无法放置返回 `null`, 可放置则返回已放置的弹幕. 30 | */ 31 | fun tryPlace(danmaku: T): D? { 32 | if (!canPlace(danmaku)) return null 33 | return place(danmaku) 34 | } 35 | 36 | /** 37 | * 清除当前轨道里的所有弹幕 38 | */ 39 | fun clearAll() 40 | 41 | /** 42 | * 需要循环执行的逻辑帧. 43 | * 44 | * 弹幕轨道上述的方法通常依赖时间来进行判断, 可执行此逻辑帧 tick 来进行判断. 45 | * 46 | * 如果不需要判断则不需要实现此方法. 47 | * 48 | * 目前的 [FloatingDanmakuTrack] 和 [FixedDanmakuTrack] 均实现了逻辑帧并进行以下行为: 49 | * - 基于帧时间判断是否需要移除轨道中的过期弹幕. 50 | * - 基于已有弹幕判断当前时间点是否可以放置一条新弹幕. 51 | */ 52 | fun tick() 53 | } -------------------------------------------------------------------------------- /danmaku/src/main/java/com/anime/danmaku/ui/Util.kt: -------------------------------------------------------------------------------- 1 | package com.anime.danmaku.ui 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.platform.LocalConfiguration 6 | import kotlin.math.round 7 | 8 | internal var LOG_ENABLE = false 9 | 10 | internal const val LOG_TAG = "Danmaku" 11 | 12 | internal fun T.log(prefix: String = ""): T { 13 | val prefixStr = if (prefix.isEmpty()) "" else "[$prefix] " 14 | if (LOG_ENABLE) { 15 | if (this is Throwable) { 16 | Log.w(LOG_TAG, prefixStr + this.message, this) 17 | } else { 18 | Log.d(LOG_TAG, prefixStr + toString()) 19 | } 20 | } 21 | return this 22 | } 23 | 24 | /** 25 | * Equivalent to `String.format("%.2f", value)` 26 | */ 27 | internal fun String.Companion.format2f(value: Float): String { 28 | return (round(value * 100) / 100.0).toString() 29 | } 30 | 31 | @Composable 32 | internal fun isInLandscapeMode(): Boolean = 33 | LocalConfiguration.current.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE -------------------------------------------------------------------------------- /download/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /download/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | } 5 | 6 | android { 7 | namespace = "com.lanlinju.download" 8 | compileSdk = 35 9 | 10 | defaultConfig { 11 | minSdk = 26 12 | 13 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 14 | consumerProguardFiles("consumer-rules.pro") 15 | } 16 | 17 | buildTypes { 18 | release { 19 | isMinifyEnabled = false 20 | proguardFiles( 21 | getDefaultProguardFile("proguard-android-optimize.txt"), 22 | "proguard-rules.pro" 23 | ) 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_17 28 | targetCompatibility = JavaVersion.VERSION_17 29 | } 30 | kotlinOptions { 31 | jvmTarget = "17" 32 | } 33 | } 34 | 35 | dependencies { 36 | implementation(libs.androidx.activity.compose) 37 | api(libs.okhttp) 38 | api(libs.retrofit) 39 | } -------------------------------------------------------------------------------- /download/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | # Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and 23 | # EnclosingMethod is required to use InnerClasses. 24 | -keepattributes Signature, InnerClasses, EnclosingMethod 25 | 26 | # Retrofit does reflection on method and parameter annotations. 27 | -keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations 28 | 29 | # Keep annotation default values (e.g., retrofit2.http.Field.encoded). 30 | -keepattributes AnnotationDefault 31 | 32 | # Retain service method parameters when optimizing. 33 | -keepclassmembers,allowshrinking,allowobfuscation interface * { 34 | @retrofit2.http.* ; 35 | } 36 | 37 | # Ignore annotation used for build tooling. 38 | -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement 39 | 40 | # Ignore JSR 305 annotations for embedding nullability information. 41 | -dontwarn javax.annotation.** 42 | 43 | # Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. 44 | -dontwarn kotlin.Unit 45 | 46 | # Top-level functions that can only be used by Kotlin. 47 | -dontwarn retrofit2.KotlinExtensions 48 | -dontwarn retrofit2.KotlinExtensions$* 49 | 50 | # With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy 51 | # and replaces all potential values with null. Explicitly keeping the interfaces prevents this. 52 | -if interface * { @retrofit2.http.* ; } 53 | -keep,allowobfuscation interface <1> 54 | 55 | # Keep inherited services. 56 | -if interface * { @retrofit2.http.* ; } 57 | -keep,allowobfuscation interface * extends <1> 58 | 59 | # With R8 full mode generic signatures are stripped for classes that are not 60 | # kept. Suspend functions are wrapped in continuations where the type argument 61 | # is used. 62 | -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation 63 | 64 | # R8 full mode strips generic signatures from return types if not kept. 65 | -if interface * { @retrofit2.http.* public *** *(...); } 66 | -keep,allowoptimization,allowshrinking,allowobfuscation class <3> 67 | 68 | # With R8 full mode generic signatures are stripped for classes that are not kept. 69 | -keep,allowobfuscation,allowshrinking class retrofit2.Response -------------------------------------------------------------------------------- /download/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /download/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/Download.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download 2 | 3 | import com.lanlinju.download.core.DownloadConfig 4 | import com.lanlinju.download.core.DownloadParam 5 | import com.lanlinju.download.core.DownloadTask 6 | import kotlinx.coroutines.CoroutineScope 7 | 8 | fun CoroutineScope.download( 9 | url: String, 10 | saveName: String = "", 11 | savePath: String , 12 | downloadConfig: DownloadConfig = DownloadConfig() 13 | ): DownloadTask { 14 | val downloadParam = DownloadParam(url, saveName, savePath) 15 | val task = DownloadTask(this, downloadParam, downloadConfig) 16 | return downloadConfig.taskManager.add(task) 17 | } 18 | 19 | fun CoroutineScope.download( 20 | downloadParam: DownloadParam, 21 | downloadConfig: DownloadConfig = DownloadConfig() 22 | ): DownloadTask { 23 | val task = DownloadTask(this, downloadParam, downloadConfig) 24 | return downloadConfig.taskManager.add(task) 25 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/Progress.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download 2 | 3 | import com.lanlinju.download.utils.formatSize 4 | import com.lanlinju.download.utils.ratio 5 | 6 | /* 7 | *如果下载的文件是m3u8类型则downloadSize,totalSize表示的是ts文件数量而不是字节数 8 | */ 9 | class Progress( 10 | var downloadSize: Long = 0, 11 | var totalSize: Long = 0, 12 | /** 13 | * 用于标识一个链接是否是分块下载, 如果该值为true, 那么totalSize为-1 14 | */ 15 | var isChunked: Boolean = false 16 | ) { 17 | /** 18 | * Return total size str. eg: 10M 19 | */ 20 | fun totalSizeStr(): String { 21 | return totalSize.formatSize() 22 | } 23 | 24 | /** 25 | * Return download size str. eg: 3M 26 | */ 27 | fun downloadSizeStr(): String { 28 | return downloadSize.formatSize() 29 | } 30 | 31 | /** 32 | * Return percent number. 33 | */ 34 | fun percent(): Double { 35 | if (isChunked) return 0.0 36 | return downloadSize ratio totalSize 37 | } 38 | 39 | /** 40 | * Return percent string. 41 | */ 42 | fun percentStr(): String { 43 | return "${percent()}%" 44 | } 45 | 46 | /** 47 | * Return progress value. Range 0.0 - 1.0 48 | */ 49 | fun progress(): Float { 50 | return (percent() * 0.01f).toFloat() 51 | } 52 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/State.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download 2 | 3 | sealed class State { 4 | class None : State() 5 | class Waiting : State() 6 | class Downloading : State() 7 | class Stopped : State() 8 | class Failed : State() 9 | class Succeed : State() 10 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/core/DownloadConfig.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.core 2 | 3 | import com.lanlinju.download.helper.Default.DEFAULT_RANGE_CURRENCY 4 | import com.lanlinju.download.helper.Default.DEFAULT_RANGE_SIZE 5 | import com.lanlinju.download.helper.apiCreator 6 | import okhttp3.ResponseBody 7 | import retrofit2.Response 8 | 9 | 10 | class DownloadConfig( 11 | /** 12 | * 禁用断点续传 13 | */ 14 | val disableRangeDownload: Boolean = false, 15 | /** 16 | * 下载管理 17 | */ 18 | val taskManager: TaskManager = DefaultTaskManager, 19 | /** 20 | * 下载队列 21 | */ 22 | val queue: DownloadQueue = DefaultDownloadQueue.get(), 23 | 24 | /** 25 | * 自定义header 26 | */ 27 | val customHeader: Map = emptyMap(), 28 | 29 | /** 30 | * 分片下载每片的大小 31 | */ 32 | val rangeSize: Long = DEFAULT_RANGE_SIZE, 33 | /** 34 | * 分片下载并行数量 35 | */ 36 | val rangeCurrency: Int = DEFAULT_RANGE_CURRENCY, 37 | 38 | /** 39 | * 下载器分发 40 | */ 41 | val dispatcher: DownloadDispatcher = DefaultDownloadDispatcher, 42 | 43 | /** 44 | * 文件校验 45 | */ 46 | val validator: FileValidator = DefaultFileValidator, 47 | 48 | /** 49 | * http client 50 | */ 51 | httpClientFactory: HttpClientFactory = DefaultHttpClientFactory 52 | ) { 53 | private val api = apiCreator(httpClientFactory.create()) 54 | 55 | suspend fun request(url: String, header: Map): Response { 56 | val tempHeader = mutableMapOf().also { 57 | it.putAll(customHeader) 58 | it.putAll(header) 59 | } 60 | return api.get(url, tempHeader) 61 | } 62 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/core/DownloadParam.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.core 2 | 3 | 4 | open class DownloadParam( 5 | var url: String, 6 | var saveName: String = "", 7 | var savePath: String, 8 | ) { 9 | 10 | /** 11 | * Each task with unique tag. 12 | */ 13 | open fun tag() = url 14 | 15 | 16 | override fun equals(other: Any?): Boolean { 17 | if (other == null) return false 18 | if (this === other) return true 19 | 20 | return if (other is DownloadParam) { 21 | tag() == other.tag() 22 | } else { 23 | false 24 | } 25 | } 26 | 27 | override fun hashCode(): Int { 28 | return tag().hashCode() 29 | } 30 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/core/DownloadQueue.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.core 2 | 3 | import com.lanlinju.download.helper.Default.MAX_TASK_NUMBER 4 | import kotlinx.coroutines.* 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.channels.consumeEach 7 | import java.util.concurrent.* 8 | 9 | interface DownloadQueue { 10 | suspend fun enqueue(task: DownloadTask) 11 | 12 | suspend fun dequeue(task: DownloadTask) 13 | } 14 | 15 | @OptIn(DelicateCoroutinesApi::class) 16 | class DefaultDownloadQueue private constructor(private val maxTask: Int) : DownloadQueue { 17 | companion object { 18 | private val lock = Any() 19 | private var instance: DefaultDownloadQueue? = null 20 | 21 | fun get(maxTask: Int = MAX_TASK_NUMBER): DefaultDownloadQueue { 22 | if (instance == null) { 23 | synchronized(lock) { 24 | if (instance == null) { 25 | instance = DefaultDownloadQueue(maxTask) 26 | } 27 | } 28 | } 29 | return instance!! 30 | } 31 | } 32 | 33 | private val channel = Channel() 34 | private val tempMap = ConcurrentHashMap() 35 | 36 | init { 37 | GlobalScope.launch { 38 | repeat(maxTask) { 39 | launch { 40 | channel.consumeEach { 41 | if (contain(it)) { 42 | it.suspendStart() 43 | dequeue(it) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | override suspend fun enqueue(task: DownloadTask) { 52 | tempMap[task.param.tag()] = task 53 | channel.send(task) 54 | } 55 | 56 | override suspend fun dequeue(task: DownloadTask) { 57 | tempMap.remove(task.param.tag()) 58 | } 59 | 60 | private fun contain(task: DownloadTask): Boolean { 61 | return tempMap[task.param.tag()] != null 62 | } 63 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/core/Downloader.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.core 2 | 3 | import com.lanlinju.download.Progress 4 | import kotlinx.coroutines.CompletableDeferred 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.DelicateCoroutinesApi 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.GlobalScope 9 | import kotlinx.coroutines.ObsoleteCoroutinesApi 10 | import kotlinx.coroutines.channels.SendChannel 11 | import kotlinx.coroutines.channels.actor 12 | import okhttp3.ResponseBody 13 | import retrofit2.Response 14 | import java.io.File 15 | 16 | class QueryProgress(val completableDeferred: CompletableDeferred) 17 | 18 | interface Downloader { 19 | var actor: SendChannel 20 | 21 | suspend fun queryProgress(): Progress 22 | 23 | /** 24 | * 用于计算下载网速 25 | * [M3u8Downloader] 需要重写这个方法 26 | */ 27 | suspend fun queryDownloadSize(): Long = queryProgress().downloadSize 28 | 29 | suspend fun download( 30 | downloadParam: DownloadParam, 31 | downloadConfig: DownloadConfig, 32 | response: Response 33 | ) 34 | } 35 | 36 | @OptIn(ObsoleteCoroutinesApi::class, DelicateCoroutinesApi::class) 37 | abstract class BaseDownloader(protected val coroutineScope: CoroutineScope) : Downloader { 38 | protected var totalSize: Long = 0L 39 | protected var downloadSize: Long = 0L 40 | protected var isChunked: Boolean = false 41 | 42 | private val progress = Progress() 43 | 44 | override var actor = GlobalScope.actor(Dispatchers.IO) { 45 | for (each in channel) { 46 | each.completableDeferred.complete(progress.also { 47 | it.downloadSize = downloadSize 48 | it.totalSize = totalSize 49 | it.isChunked = isChunked 50 | }) 51 | } 52 | } 53 | 54 | override suspend fun queryProgress(): Progress { 55 | val ack = CompletableDeferred() 56 | val queryProgress = QueryProgress(ack) 57 | actor.send(queryProgress) 58 | return ack.await() 59 | } 60 | 61 | fun DownloadParam.dir(): File { 62 | return File(savePath) 63 | } 64 | 65 | fun DownloadParam.file(): File { 66 | return File(savePath, saveName) 67 | } 68 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/core/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.core 2 | 3 | import com.lanlinju.download.utils.contentLength 4 | import com.lanlinju.download.utils.isSupportRange 5 | import com.lanlinju.download.utils.log 6 | import okhttp3.OkHttpClient 7 | import okhttp3.Protocol 8 | import okhttp3.ResponseBody 9 | import retrofit2.Response 10 | import java.io.File 11 | import java.util.concurrent.TimeUnit 12 | 13 | interface HttpClientFactory { 14 | fun create(): OkHttpClient 15 | } 16 | 17 | object DefaultHttpClientFactory : HttpClientFactory { 18 | override fun create(): OkHttpClient { 19 | return OkHttpClient().newBuilder() 20 | .protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) 21 | .connectTimeout(15, TimeUnit.SECONDS) 22 | .readTimeout(120, TimeUnit.SECONDS) 23 | .writeTimeout(120, TimeUnit.SECONDS) 24 | .build() 25 | } 26 | } 27 | 28 | interface DownloadDispatcher { 29 | fun dispatch(downloadTask: DownloadTask, resp: Response): Downloader 30 | } 31 | 32 | object DefaultDownloadDispatcher : DownloadDispatcher { 33 | override fun dispatch(downloadTask: DownloadTask, resp: Response): Downloader { 34 | return if (downloadTask.param.url.contains("m3u8")) { 35 | "M3u8Downloader".log() 36 | M3u8Downloader(downloadTask.coroutineScope) 37 | } else if (downloadTask.config.disableRangeDownload || !resp.isSupportRange()) { 38 | "NormalDownloader".log() 39 | NormalDownloader(downloadTask.coroutineScope) 40 | } else { 41 | "RangeDownloader".log() 42 | RangeDownloader(downloadTask.coroutineScope) 43 | } 44 | } 45 | } 46 | 47 | interface FileValidator { 48 | fun validate( 49 | file: File, 50 | param: DownloadParam, 51 | resp: Response 52 | ): Boolean 53 | } 54 | 55 | object DefaultFileValidator : FileValidator { 56 | override fun validate( 57 | file: File, 58 | param: DownloadParam, 59 | resp: Response 60 | ): Boolean { 61 | return file.length() == resp.contentLength() 62 | } 63 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/core/NormalDownloader.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.core 2 | 3 | import com.lanlinju.download.utils.closeQuietly 4 | import com.lanlinju.download.utils.contentLength 5 | import com.lanlinju.download.utils.isChunked 6 | import com.lanlinju.download.utils.recreate 7 | import com.lanlinju.download.utils.shadow 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.async 11 | import kotlinx.coroutines.coroutineScope 12 | import kotlinx.coroutines.isActive 13 | import okhttp3.ResponseBody 14 | import okio.buffer 15 | import okio.sink 16 | import retrofit2.Response 17 | import java.io.File 18 | 19 | class NormalDownloader(coroutineScope: CoroutineScope) : BaseDownloader(coroutineScope) { 20 | companion object { 21 | private const val BUFFER_SIZE = 8192L 22 | } 23 | 24 | private var alreadyDownloaded = false 25 | 26 | private lateinit var file: File 27 | private lateinit var shadowFile: File 28 | 29 | override suspend fun download( 30 | downloadParam: DownloadParam, 31 | downloadConfig: DownloadConfig, 32 | response: Response 33 | ) { 34 | try { 35 | file = downloadParam.file() 36 | shadowFile = file.shadow() 37 | 38 | val contentLength = response.contentLength() 39 | val isChunked = response.isChunked() 40 | 41 | downloadPrepare(downloadParam, contentLength) 42 | 43 | if (alreadyDownloaded) { 44 | this.downloadSize = contentLength 45 | this.totalSize = contentLength 46 | this.isChunked = isChunked 47 | } else { 48 | this.totalSize = contentLength 49 | this.downloadSize = 0 50 | this.isChunked = isChunked 51 | startDownload(response.body()!!) 52 | } 53 | } finally { 54 | response.closeQuietly() 55 | } 56 | } 57 | 58 | private fun downloadPrepare(downloadParam: DownloadParam, contentLength: Long) { 59 | //make sure dir is exists 60 | val fileDir = downloadParam.dir() 61 | if (!fileDir.exists() || !fileDir.isDirectory) { 62 | fileDir.mkdirs() 63 | } 64 | 65 | if (file.exists()) { 66 | if (file.length() == contentLength) { 67 | alreadyDownloaded = true 68 | } else { 69 | file.delete() 70 | shadowFile.recreate() 71 | } 72 | } else { 73 | shadowFile.recreate() 74 | } 75 | } 76 | 77 | private suspend fun startDownload(body: ResponseBody) = coroutineScope { 78 | val deferred = async(Dispatchers.IO) { 79 | val source = body.source() 80 | val sink = shadowFile.sink().buffer() 81 | val buffer = sink.buffer 82 | 83 | var readLen = source.read(buffer, BUFFER_SIZE) 84 | while (isActive && readLen != -1L) { 85 | downloadSize += readLen 86 | readLen = source.read(buffer, BUFFER_SIZE) 87 | sink.flush() 88 | } 89 | sink.flush() 90 | } 91 | deferred.await() 92 | 93 | if (isActive) { 94 | shadowFile.renameTo(file) 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/core/TaskManager.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.core 2 | 3 | import java.util.concurrent.ConcurrentHashMap 4 | 5 | interface TaskManager { 6 | fun add(task: DownloadTask): DownloadTask 7 | 8 | fun remove(task: DownloadTask) 9 | } 10 | 11 | object DefaultTaskManager : TaskManager { 12 | private val taskMap = ConcurrentHashMap() 13 | 14 | override fun add(task: DownloadTask): DownloadTask { 15 | if (taskMap[task.param.tag()] == null) { 16 | taskMap[task.param.tag()] = task 17 | } 18 | return taskMap[task.param.tag()]!! 19 | } 20 | 21 | override fun remove(task: DownloadTask) { 22 | taskMap.remove(task.param.tag()) 23 | } 24 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/helper/Default.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.helper 2 | 3 | object Default { 4 | /** 5 | * 默认的分片大小 6 | */ 7 | const val DEFAULT_RANGE_SIZE = 5L * 1024 * 1024 8 | 9 | /** 10 | * 单个任务同时下载的分片数量 11 | */ 12 | const val DEFAULT_RANGE_CURRENCY = 5 13 | 14 | /** 15 | * 同时下载的任务数量 16 | */ 17 | const val MAX_TASK_NUMBER = 3 18 | 19 | /** 20 | * 默认的Header 21 | */ 22 | val RANGE_CHECK_HEADER = mapOf("Range" to "bytes=0-") 23 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/helper/Request.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.helper 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.ResponseBody 5 | import retrofit2.Response 6 | import retrofit2.Retrofit 7 | import retrofit2.http.GET 8 | import retrofit2.http.HeaderMap 9 | import retrofit2.http.Streaming 10 | import retrofit2.http.Url 11 | 12 | internal const val FAKE_BASE_URL = "http://www.example.com" 13 | 14 | internal fun apiCreator(client: OkHttpClient): Api { 15 | val retrofit = Retrofit.Builder() 16 | .baseUrl(FAKE_BASE_URL) 17 | .client(client) 18 | .build() 19 | return retrofit.create(Api::class.java) 20 | } 21 | 22 | internal interface Api { 23 | 24 | @GET 25 | @Streaming 26 | suspend fun get( 27 | @Url url: String, 28 | @HeaderMap headers: Map 29 | ): Response 30 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/utils/FileUtils.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.utils 2 | 3 | import java.io.File 4 | import java.io.RandomAccessFile 5 | import java.nio.MappedByteBuffer 6 | import java.nio.channels.FileChannel 7 | 8 | fun File.shadow(): File { 9 | val shadowPath = "$canonicalPath.download" 10 | return File(shadowPath) 11 | } 12 | 13 | fun File.tmp(): File { 14 | val tmpPath = "$canonicalPath.tmp" 15 | return File(tmpPath) 16 | } 17 | 18 | fun File.tsFile(index: Long): File { 19 | val tsPath = "$canonicalPath-${index}.ts" 20 | return File(tsPath) 21 | } 22 | 23 | fun File.recreate(length: Long = 0L) { 24 | delete() 25 | val created = createNewFile() 26 | if (created) { 27 | setLength(length) 28 | } else { 29 | throw IllegalStateException("File create failed!") 30 | } 31 | } 32 | 33 | fun File.setLength(length: Long = 0L) { 34 | RandomAccessFile(this, "rw").setLength(length) 35 | } 36 | 37 | fun File.channel(): FileChannel { 38 | return RandomAccessFile(this, "rw").channel 39 | } 40 | 41 | fun File.mappedByteBuffer(position: Long, size: Long): MappedByteBuffer { 42 | val channel = channel() 43 | val map = channel.map(FileChannel.MapMode.READ_WRITE, position, size) 44 | channel.closeQuietly() 45 | return map 46 | } 47 | 48 | fun File.clear() { 49 | val shadow = shadow() 50 | val tmp = tmp() 51 | 52 | for (f in parentFile.listFiles()!!) { 53 | if (f.name.startsWith("${this.name}-") && f.name.endsWith(".ts")) f.delete() 54 | } 55 | 56 | shadow.delete() 57 | tmp.delete() 58 | delete() 59 | } 60 | 61 | fun File.handleFormat() { 62 | val headFile = RandomAccessFile(this, "rw") 63 | val bytes = ByteArray(8) 64 | headFile.read(bytes) 65 | if (!String(bytes).contains("PNG")) { 66 | headFile.close() 67 | return 68 | } 69 | bytes.fill(0xff.toByte()) 70 | headFile.seek(0L) 71 | headFile.write(bytes, 0, bytes.size) 72 | headFile.close() 73 | } 74 | -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/utils/HttpUtil.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.utils 2 | 3 | import okhttp3.ResponseBody 4 | import retrofit2.Response 5 | import java.io.Closeable 6 | import java.util.* 7 | import java.util.regex.Pattern 8 | 9 | /** Closes this, ignoring any checked exceptions. */ 10 | fun Closeable.closeQuietly() { 11 | try { 12 | close() 13 | } catch (rethrown: RuntimeException) { 14 | throw rethrown 15 | } catch (_: Exception) { 16 | } 17 | } 18 | 19 | fun Response.closeQuietly() { 20 | body()?.closeQuietly() 21 | errorBody()?.closeQuietly() 22 | } 23 | 24 | fun Response<*>.url(): String { 25 | return raw().request.url.toString() 26 | } 27 | 28 | fun Response<*>.contentLength(): Long { 29 | return header("Content-Length").toLongOrDefault(-1) 30 | } 31 | 32 | fun Response<*>.isChunked(): Boolean { 33 | return header("Transfer-Encoding") == "chunked" 34 | } 35 | 36 | fun Response<*>.isSupportRange(): Boolean { 37 | if (code() == 206 38 | || header("Content-Range").isNotEmpty() 39 | || header("Accept-Ranges") == "bytes" 40 | ) { 41 | return true 42 | } 43 | return false 44 | } 45 | 46 | fun Response<*>.fileName(): String { 47 | val url = url() 48 | 49 | var fileName = contentDisposition() 50 | if (fileName.isEmpty()) { 51 | fileName = getFileNameFromUrl(url) 52 | } 53 | 54 | return fileName 55 | } 56 | 57 | fun Response<*>.calcRanges(rangeSize: Long): Long { 58 | val totalSize = contentLength() 59 | val remainder = totalSize % rangeSize 60 | val result = totalSize / rangeSize 61 | 62 | return if (remainder == 0L) { 63 | result 64 | } else { 65 | result + 1 66 | } 67 | } 68 | 69 | private fun Response<*>.contentDisposition(): String { 70 | val contentDisposition = header("Content-Disposition").lowercase(Locale.getDefault()) 71 | 72 | if (contentDisposition.isEmpty()) { 73 | return "" 74 | } 75 | 76 | val matcher = Pattern.compile(".*filename=(.*)").matcher(contentDisposition) 77 | if (!matcher.find()) { 78 | return "" 79 | } 80 | 81 | var result = matcher.group(1) 82 | if (result.startsWith("\"")) { 83 | result = result.substring(1) 84 | } 85 | if (result.endsWith("\"")) { 86 | result = result.substring(0, result.length - 1) 87 | } 88 | 89 | result = result.replace("/", "_", false) 90 | 91 | return result 92 | } 93 | 94 | fun getFileNameFromUrl(url: String): String { 95 | var temp = url 96 | if (temp.isNotEmpty()) { 97 | val fragment = temp.lastIndexOf('#') 98 | if (fragment > 0) { 99 | temp = temp.substring(0, fragment) 100 | } 101 | 102 | val query = temp.lastIndexOf('?') 103 | if (query > 0) { 104 | temp = temp.substring(0, query) 105 | } 106 | 107 | val filenamePos = temp.lastIndexOf('/') 108 | val filename = if (0 <= filenamePos) temp.substring(filenamePos + 1) else temp 109 | 110 | if (filename.isNotEmpty() && Pattern.matches("[a-zA-Z_0-9.\\-()%]+", filename)) { 111 | return filename 112 | } 113 | } 114 | 115 | return "" 116 | } 117 | 118 | private fun Response<*>.header(key: String): String { 119 | val header = headers()[key] 120 | return header ?: "" 121 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/utils/LogUtil.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.utils 2 | 3 | import android.util.Log 4 | 5 | internal var LOG_ENABLE = false 6 | 7 | internal const val LOG_TAG = "Download" 8 | 9 | internal fun T.log(prefix: String = ""): T { 10 | val prefixStr = if (prefix.isEmpty()) "" else "[$prefix] " 11 | if (LOG_ENABLE) { 12 | if (this is Throwable) { 13 | Log.w(LOG_TAG, prefixStr + this.message, this) 14 | } else { 15 | Log.d(LOG_TAG, prefixStr + toString()) 16 | } 17 | } 18 | return this 19 | } -------------------------------------------------------------------------------- /download/src/main/java/com/lanlinju/download/utils/Util.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.download.utils 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.channels.Channel 5 | import kotlinx.coroutines.channels.consumeEach 6 | import java.math.RoundingMode 7 | import java.util.concurrent.atomic.AtomicInteger 8 | import javax.crypto.Cipher 9 | import javax.crypto.spec.IvParameterSpec 10 | import javax.crypto.spec.SecretKeySpec 11 | 12 | fun String.toLongOrDefault(defaultValue: Long): Long { 13 | return try { 14 | toLong() 15 | } catch (_: NumberFormatException) { 16 | defaultValue 17 | } 18 | } 19 | 20 | fun Long.formatSize(): String { 21 | require(this >= 0) { "Size must larger than 0." } 22 | 23 | val byte = this.toDouble() 24 | val kb = byte / 1024.0 25 | val mb = byte / 1024.0 / 1024.0 26 | val gb = byte / 1024.0 / 1024.0 / 1024.0 27 | val tb = byte / 1024.0 / 1024.0 / 1024.0 / 1024.0 28 | 29 | return when { 30 | tb >= 1 -> "${tb.decimal(2)} TB" 31 | gb >= 1 -> "${gb.decimal(2)} GB" 32 | mb >= 1 -> "${mb.decimal(2)} MB" 33 | kb >= 1 -> "${kb.decimal(2)} KB" 34 | else -> "${byte.decimal(2)} B" 35 | } 36 | } 37 | 38 | fun Double.decimal(digits: Int): Double { 39 | return this.toBigDecimal() 40 | .setScale(digits, RoundingMode.HALF_UP) 41 | .toDouble() 42 | } 43 | 44 | infix fun Long.ratio(bottom: Long): Double { 45 | if (bottom <= 0) { 46 | return 0.0 47 | } 48 | val result = (this * 100.0).toBigDecimal() 49 | .divide((bottom * 1.0).toBigDecimal(), 2, RoundingMode.FLOOR) 50 | return result.toDouble() 51 | } 52 | 53 | suspend fun (Collection).parallel( 54 | dispatcher: CoroutineDispatcher = Dispatchers.Default, 55 | max: Int = 2, 56 | action: suspend CoroutineScope.(T) -> R 57 | ): Iterable = coroutineScope { 58 | val list = this@parallel 59 | if (list.isEmpty()) return@coroutineScope listOf() 60 | 61 | val channel = Channel() 62 | val output = Channel() 63 | 64 | val counter = AtomicInteger(0) 65 | 66 | launch { 67 | list.forEach { channel.send(it) } 68 | channel.close() 69 | } 70 | 71 | repeat(max) { 72 | launch(dispatcher) { 73 | channel.consumeEach { 74 | output.send(action(it)) 75 | val completed = counter.incrementAndGet() 76 | if (completed == list.size) { 77 | output.close() 78 | } 79 | } 80 | } 81 | } 82 | 83 | val results = mutableListOf() 84 | for (item in output) { 85 | results.add(item) 86 | } 87 | 88 | return@coroutineScope results 89 | } 90 | 91 | fun ByteArray.decrypt(key: String, iv: String): ByteArray { 92 | val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") 93 | val keySpec = SecretKeySpec(key.toByteArray(), "AES") 94 | cipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(iv.toByteArray())) 95 | return cipher.doFinal(this) 96 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Sep 13 21:06:17 CST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | LaQoo - 免费观看动漫APP,支持下载、弹幕、收藏多源播放 9 | 10 | 11 | 12 | 13 | 14 | 66 | 67 | 68 |
69 |
70 | 71 | LaQoo APP logo 72 |

LaQoo

73 |

74 | 简洁的播放动漫的App 75 |

76 |

80 | 支持下载,弹幕,收藏,多动漫源等功能 81 |

82 |

86 | 版本:1.2.0 87 |

88 | 立即下载 94 | 加入QQ频道 100 | 源代码 106 |
107 |
108 | 109 | 110 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/logo.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | maven { setUrl("https://maven.aliyun.com/repository/central") } 4 | maven { setUrl("https://maven.aliyun.com/repository/jcenter") } 5 | maven { setUrl("https://maven.aliyun.com/repository/google") } 6 | maven { setUrl("https://maven.aliyun.com/repository/gradle-plugin") } 7 | maven { setUrl("https://maven.aliyun.com/repository/public") } 8 | maven { setUrl("https://jitpack.io") } 9 | google() 10 | mavenCentral() 11 | gradlePluginPortal() 12 | } 13 | } 14 | dependencyResolutionManagement { 15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 | repositories { 17 | maven { setUrl("https://maven.aliyun.com/repository/central") } 18 | maven { setUrl("https://maven.aliyun.com/repository/jcenter") } 19 | maven { setUrl("https://maven.aliyun.com/repository/google") } 20 | maven { setUrl("https://maven.aliyun.com/repository/gradle-plugin") } 21 | maven { setUrl("https://maven.aliyun.com/repository/public") } 22 | maven { setUrl("https://jitpack.io") } 23 | google() 24 | mavenCentral() 25 | } 26 | } 27 | 28 | rootProject.name = "LaQoo" 29 | include(":app") 30 | include(":video-player") 31 | include(":download") 32 | include(":danmaku") 33 | -------------------------------------------------------------------------------- /video-player/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /video-player/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "com.lanlinju.videoplayer" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | minSdk = 26 13 | 14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles("consumer-rules.pro") 16 | } 17 | 18 | buildTypes { 19 | release { 20 | isMinifyEnabled = false 21 | proguardFiles( 22 | getDefaultProguardFile("proguard-android-optimize.txt"), 23 | "proguard-rules.pro" 24 | ) 25 | } 26 | } 27 | buildFeatures { 28 | compose = true 29 | } 30 | compileOptions { 31 | sourceCompatibility = JavaVersion.VERSION_17 32 | targetCompatibility = JavaVersion.VERSION_17 33 | } 34 | kotlinOptions { 35 | jvmTarget = "17" 36 | } 37 | } 38 | 39 | dependencies { 40 | implementation(libs.androidx.activity.compose) 41 | implementation(libs.androidx.ui) 42 | implementation(libs.androidx.material3) 43 | implementation(platform(libs.androidx.compose.bom)) 44 | // api("com.google.android.exoplayer:exoplayer-core:2.19.1") 45 | // api("com.google.android.exoplayer:exoplayer-hls:2.19.1") 46 | api(libs.androidx.media3.exoplayer) 47 | api(libs.androidx.media3.exoplayer.hls) 48 | } -------------------------------------------------------------------------------- /video-player/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laqoome/LaQoo/6278817030a631f07715619ec51f090df485ee9d/video-player/consumer-rules.pro -------------------------------------------------------------------------------- /video-player/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /video-player/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /video-player/src/main/java/com/lanlinju/videoplayer/icons/ArrowBackIos.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.videoplayer.icons 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.materialIcon 5 | import androidx.compose.material.icons.materialPath 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | internal val Icons.Rounded.ArrowBackIos: ImageVector 9 | get() { 10 | if (_arrowBackIos != null) { 11 | return _arrowBackIos!! 12 | } 13 | _arrowBackIos = materialIcon(name = "Rounded.ArrowBackIos") { 14 | materialPath { 15 | moveTo(16.62f, 2.99f) 16 | curveToRelative(-0.49f, -0.49f, -1.28f, -0.49f, -1.77f, 0.0f) 17 | lineTo(6.54f, 11.3f) 18 | curveToRelative(-0.39f, 0.39f, -0.39f, 1.02f, 0.0f, 1.41f) 19 | lineToRelative(8.31f, 8.31f) 20 | curveToRelative(0.49f, 0.49f, 1.28f, 0.49f, 1.77f, 0.0f) 21 | reflectiveCurveToRelative(0.49f, -1.28f, 0.0f, -1.77f) 22 | lineTo(9.38f, 12.0f) 23 | lineToRelative(7.25f, -7.25f) 24 | curveToRelative(0.48f, -0.48f, 0.48f, -1.28f, -0.01f, -1.76f) 25 | close() 26 | } 27 | } 28 | return _arrowBackIos!! 29 | } 30 | 31 | private var _arrowBackIos: ImageVector? = null 32 | -------------------------------------------------------------------------------- /video-player/src/main/java/com/lanlinju/videoplayer/icons/Fullscreen.kt: -------------------------------------------------------------------------------- 1 | package com.imherrera.videoplayer.icons 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.materialIcon 5 | import androidx.compose.material.icons.materialPath 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | internal val Icons.Rounded.Fullscreen: ImageVector 9 | get() { 10 | if (_fullscreen != null) { 11 | return _fullscreen!! 12 | } 13 | _fullscreen = materialIcon(name = "Rounded.Fullscreen") { 14 | materialPath { 15 | moveTo(6.0f, 14.0f) 16 | curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) 17 | verticalLineToRelative(3.0f) 18 | curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) 19 | horizontalLineToRelative(3.0f) 20 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 21 | reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) 22 | lineTo(7.0f, 17.0f) 23 | verticalLineToRelative(-2.0f) 24 | curveToRelative(0.0f, -0.55f, -0.45f, -1.0f, -1.0f, -1.0f) 25 | close() 26 | moveTo(6.0f, 10.0f) 27 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 28 | lineTo(7.0f, 7.0f) 29 | horizontalLineToRelative(2.0f) 30 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 31 | reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) 32 | lineTo(6.0f, 5.0f) 33 | curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) 34 | verticalLineToRelative(3.0f) 35 | curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) 36 | close() 37 | moveTo(17.0f, 17.0f) 38 | horizontalLineToRelative(-2.0f) 39 | curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) 40 | reflectiveCurveToRelative(0.45f, 1.0f, 1.0f, 1.0f) 41 | horizontalLineToRelative(3.0f) 42 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 43 | verticalLineToRelative(-3.0f) 44 | curveToRelative(0.0f, -0.55f, -0.45f, -1.0f, -1.0f, -1.0f) 45 | reflectiveCurveToRelative(-1.0f, 0.45f, -1.0f, 1.0f) 46 | verticalLineToRelative(2.0f) 47 | close() 48 | moveTo(14.0f, 6.0f) 49 | curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) 50 | horizontalLineToRelative(2.0f) 51 | verticalLineToRelative(2.0f) 52 | curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) 53 | reflectiveCurveToRelative(1.0f, -0.45f, 1.0f, -1.0f) 54 | lineTo(19.0f, 6.0f) 55 | curveToRelative(0.0f, -0.55f, -0.45f, -1.0f, -1.0f, -1.0f) 56 | horizontalLineToRelative(-3.0f) 57 | curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) 58 | close() 59 | } 60 | } 61 | return _fullscreen!! 62 | } 63 | 64 | private var _fullscreen: ImageVector? = null -------------------------------------------------------------------------------- /video-player/src/main/java/com/lanlinju/videoplayer/icons/FullscreenExit.kt: -------------------------------------------------------------------------------- 1 | package com.imherrera.videoplayer.icons 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.materialIcon 5 | import androidx.compose.material.icons.materialPath 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | internal val Icons.Rounded.FullscreenExit: ImageVector 9 | get() { 10 | if (_fullscreenExit != null) { 11 | return _fullscreenExit!! 12 | } 13 | _fullscreenExit = materialIcon(name = "Rounded.FullscreenExit") { 14 | materialPath { 15 | moveTo(6.0f, 16.0f) 16 | horizontalLineToRelative(2.0f) 17 | verticalLineToRelative(2.0f) 18 | curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) 19 | reflectiveCurveToRelative(1.0f, -0.45f, 1.0f, -1.0f) 20 | verticalLineToRelative(-3.0f) 21 | curveToRelative(0.0f, -0.55f, -0.45f, -1.0f, -1.0f, -1.0f) 22 | lineTo(6.0f, 14.0f) 23 | curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) 24 | reflectiveCurveToRelative(0.45f, 1.0f, 1.0f, 1.0f) 25 | close() 26 | moveTo(8.0f, 8.0f) 27 | lineTo(6.0f, 8.0f) 28 | curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) 29 | reflectiveCurveToRelative(0.45f, 1.0f, 1.0f, 1.0f) 30 | horizontalLineToRelative(3.0f) 31 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 32 | lineTo(10.0f, 6.0f) 33 | curveToRelative(0.0f, -0.55f, -0.45f, -1.0f, -1.0f, -1.0f) 34 | reflectiveCurveToRelative(-1.0f, 0.45f, -1.0f, 1.0f) 35 | verticalLineToRelative(2.0f) 36 | close() 37 | moveTo(15.0f, 19.0f) 38 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 39 | verticalLineToRelative(-2.0f) 40 | horizontalLineToRelative(2.0f) 41 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 42 | reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) 43 | horizontalLineToRelative(-3.0f) 44 | curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) 45 | verticalLineToRelative(3.0f) 46 | curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) 47 | close() 48 | moveTo(16.0f, 8.0f) 49 | lineTo(16.0f, 6.0f) 50 | curveToRelative(0.0f, -0.55f, -0.45f, -1.0f, -1.0f, -1.0f) 51 | reflectiveCurveToRelative(-1.0f, 0.45f, -1.0f, 1.0f) 52 | verticalLineToRelative(3.0f) 53 | curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) 54 | horizontalLineToRelative(3.0f) 55 | curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) 56 | reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) 57 | horizontalLineToRelative(-2.0f) 58 | close() 59 | } 60 | } 61 | return _fullscreenExit!! 62 | } 63 | 64 | private var _fullscreenExit: ImageVector? = null -------------------------------------------------------------------------------- /video-player/src/main/java/com/lanlinju/videoplayer/icons/Pause.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.videoplayer.icons 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.materialIcon 5 | import androidx.compose.material.icons.materialPath 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | internal val Icons.Rounded.Pause: ImageVector 9 | get() { 10 | if (_pause != null) { 11 | return _pause!! 12 | } 13 | _pause = materialIcon(name = "Rounded.Pause") { 14 | materialPath { 15 | moveTo(8.0f, 19.0f) 16 | curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f) 17 | lineTo(10.0f, 7.0f) 18 | curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) 19 | reflectiveCurveToRelative(-2.0f, 0.9f, -2.0f, 2.0f) 20 | verticalLineToRelative(10.0f) 21 | curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) 22 | close() 23 | moveTo(14.0f, 7.0f) 24 | verticalLineToRelative(10.0f) 25 | curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) 26 | reflectiveCurveToRelative(2.0f, -0.9f, 2.0f, -2.0f) 27 | lineTo(18.0f, 7.0f) 28 | curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) 29 | reflectiveCurveToRelative(-2.0f, 0.9f, -2.0f, 2.0f) 30 | close() 31 | } 32 | } 33 | return _pause!! 34 | } 35 | 36 | private var _pause: ImageVector? = null -------------------------------------------------------------------------------- /video-player/src/main/java/com/lanlinju/videoplayer/icons/PlayArrow.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.videoplayer.icons 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.materialIcon 5 | import androidx.compose.material.icons.materialPath 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | internal val Icons.Rounded.PlayArrow: ImageVector 9 | get() { 10 | if (_playArrow != null) { 11 | return _playArrow!! 12 | } 13 | _playArrow = materialIcon(name = "Rounded.PlayArrow") { 14 | materialPath { 15 | moveTo(8.0f, 6.82f) 16 | verticalLineToRelative(10.36f) 17 | curveToRelative(0.0f, 0.79f, 0.87f, 1.27f, 1.54f, 0.84f) 18 | lineToRelative(8.14f, -5.18f) 19 | curveToRelative(0.62f, -0.39f, 0.62f, -1.29f, 0.0f, -1.69f) 20 | lineTo(9.54f, 5.98f) 21 | curveTo(8.87f, 5.55f, 8.0f, 6.03f, 8.0f, 6.82f) 22 | close() 23 | } 24 | } 25 | return _playArrow!! 26 | } 27 | 28 | private var _playArrow: ImageVector? = null -------------------------------------------------------------------------------- /video-player/src/main/java/com/lanlinju/videoplayer/icons/Subtitles.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.videoplayer.icons 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.materialIcon 5 | import androidx.compose.material.icons.materialPath 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | public val Icons.Rounded.Subtitles: ImageVector 9 | get() { 10 | if (_subtitles != null) { 11 | return _subtitles!! 12 | } 13 | _subtitles = materialIcon(name = "Rounded.Subtitles") { 14 | materialPath { 15 | moveTo(20.0f, 4.0f) 16 | lineTo(4.0f, 4.0f) 17 | curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) 18 | verticalLineToRelative(12.0f) 19 | curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) 20 | horizontalLineToRelative(16.0f) 21 | curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f) 22 | lineTo(22.0f, 6.0f) 23 | curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) 24 | close() 25 | moveTo(5.0f, 12.0f) 26 | horizontalLineToRelative(2.0f) 27 | curveToRelative(0.55f, 0.0f, 1.0f, 0.45f, 1.0f, 1.0f) 28 | reflectiveCurveToRelative(-0.45f, 1.0f, -1.0f, 1.0f) 29 | lineTo(5.0f, 14.0f) 30 | curveToRelative(-0.55f, 0.0f, -1.0f, -0.45f, -1.0f, -1.0f) 31 | reflectiveCurveToRelative(0.45f, -1.0f, 1.0f, -1.0f) 32 | close() 33 | moveTo(13.0f, 18.0f) 34 | lineTo(5.0f, 18.0f) 35 | curveToRelative(-0.55f, 0.0f, -1.0f, -0.45f, -1.0f, -1.0f) 36 | reflectiveCurveToRelative(0.45f, -1.0f, 1.0f, -1.0f) 37 | horizontalLineToRelative(8.0f) 38 | curveToRelative(0.55f, 0.0f, 1.0f, 0.45f, 1.0f, 1.0f) 39 | reflectiveCurveToRelative(-0.45f, 1.0f, -1.0f, 1.0f) 40 | close() 41 | moveTo(19.0f, 18.0f) 42 | horizontalLineToRelative(-2.0f) 43 | curveToRelative(-0.55f, 0.0f, -1.0f, -0.45f, -1.0f, -1.0f) 44 | reflectiveCurveToRelative(0.45f, -1.0f, 1.0f, -1.0f) 45 | horizontalLineToRelative(2.0f) 46 | curveToRelative(0.55f, 0.0f, 1.0f, 0.45f, 1.0f, 1.0f) 47 | reflectiveCurveToRelative(-0.45f, 1.0f, -1.0f, 1.0f) 48 | close() 49 | moveTo(19.0f, 14.0f) 50 | horizontalLineToRelative(-8.0f) 51 | curveToRelative(-0.55f, 0.0f, -1.0f, -0.45f, -1.0f, -1.0f) 52 | reflectiveCurveToRelative(0.45f, -1.0f, 1.0f, -1.0f) 53 | horizontalLineToRelative(8.0f) 54 | curveToRelative(0.55f, 0.0f, 1.0f, 0.45f, 1.0f, 1.0f) 55 | reflectiveCurveToRelative(-0.45f, 1.0f, -1.0f, 1.0f) 56 | close() 57 | } 58 | } 59 | return _subtitles!! 60 | } 61 | 62 | private var _subtitles: ImageVector? = null -------------------------------------------------------------------------------- /video-player/src/main/java/com/lanlinju/videoplayer/icons/SubtitlesOff.kt: -------------------------------------------------------------------------------- 1 | package com.lanlinju.videoplayer.icons 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.materialIcon 5 | import androidx.compose.material.icons.materialPath 6 | import androidx.compose.ui.graphics.vector.ImageVector 7 | 8 | public val Icons.Rounded.SubtitlesOff: ImageVector 9 | get() { 10 | if (_subtitlesOff != null) { 11 | return _subtitlesOff!! 12 | } 13 | _subtitlesOff = materialIcon(name = "Rounded.SubtitlesOff") { 14 | materialPath { 15 | moveTo(20.0f, 4.0f) 16 | horizontalLineTo(6.83f) 17 | lineToRelative(8.0f, 8.0f) 18 | horizontalLineTo(19.0f) 19 | curveToRelative(0.55f, 0.0f, 1.0f, 0.45f, 1.0f, 1.0f) 20 | curveToRelative(0.0f, 0.55f, -0.45f, 1.0f, -1.0f, 1.0f) 21 | horizontalLineToRelative(-2.17f) 22 | lineToRelative(4.93f, 4.93f) 23 | curveTo(21.91f, 18.65f, 22.0f, 18.34f, 22.0f, 18.0f) 24 | verticalLineTo(6.0f) 25 | curveTo(22.0f, 4.9f, 21.1f, 4.0f, 20.0f, 4.0f) 26 | close() 27 | } 28 | materialPath { 29 | moveTo(20.0f, 20.0f) 30 | lineToRelative(-6.0f, -6.0f) 31 | lineToRelative(-1.71f, -1.71f) 32 | lineTo(12.0f, 12.0f) 33 | lineTo(3.16f, 3.16f) 34 | curveToRelative(-0.39f, -0.39f, -1.02f, -0.39f, -1.41f, 0.0f) 35 | curveToRelative(-0.39f, 0.39f, -0.39f, 1.02f, 0.0f, 1.41f) 36 | lineToRelative(0.49f, 0.49f) 37 | curveTo(2.09f, 5.35f, 2.0f, 5.66f, 2.0f, 6.0f) 38 | verticalLineToRelative(12.0f) 39 | curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) 40 | horizontalLineToRelative(13.17f) 41 | lineToRelative(2.25f, 2.25f) 42 | curveToRelative(0.39f, 0.39f, 1.02f, 0.39f, 1.41f, 0.0f) 43 | curveToRelative(0.39f, -0.39f, 0.39f, -1.02f, 0.0f, -1.41f) 44 | lineTo(20.0f, 20.0f) 45 | close() 46 | moveTo(8.0f, 13.0f) 47 | curveToRelative(0.0f, 0.55f, -0.45f, 1.0f, -1.0f, 1.0f) 48 | horizontalLineTo(5.0f) 49 | curveToRelative(-0.55f, 0.0f, -1.0f, -0.45f, -1.0f, -1.0f) 50 | curveToRelative(0.0f, -0.55f, 0.45f, -1.0f, 1.0f, -1.0f) 51 | horizontalLineToRelative(2.0f) 52 | curveTo(7.55f, 12.0f, 8.0f, 12.45f, 8.0f, 13.0f) 53 | close() 54 | moveTo(14.0f, 17.0f) 55 | curveToRelative(0.0f, 0.55f, -0.45f, 1.0f, -1.0f, 1.0f) 56 | horizontalLineTo(5.0f) 57 | curveToRelative(-0.55f, 0.0f, -1.0f, -0.45f, -1.0f, -1.0f) 58 | curveToRelative(0.0f, -0.55f, 0.45f, -1.0f, 1.0f, -1.0f) 59 | horizontalLineToRelative(8.0f) 60 | curveToRelative(0.08f, 0.0f, 0.14f, 0.03f, 0.21f, 0.04f) 61 | lineToRelative(0.74f, 0.74f) 62 | curveTo(13.97f, 16.86f, 14.0f, 16.92f, 14.0f, 17.0f) 63 | close() 64 | } 65 | } 66 | return _subtitlesOff!! 67 | } 68 | 69 | private var _subtitlesOff: ImageVector? = null -------------------------------------------------------------------------------- /video-player/src/main/res/drawable/ic_next.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 12 | --------------------------------------------------------------------------------