├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── rerere │ │ └── iwara4a │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── rerere │ │ │ └── iwara4a │ │ │ ├── AppContext.kt │ │ │ ├── api │ │ │ ├── IwaraApi.kt │ │ │ ├── IwaraApiImpl.kt │ │ │ ├── Response.kt │ │ │ ├── paging │ │ │ │ ├── CommentSource.kt │ │ │ │ ├── MediaSource.kt │ │ │ │ ├── SearchSource.kt │ │ │ │ └── SubscriptionsSource.kt │ │ │ └── service │ │ │ │ ├── IwaraParser.kt │ │ │ │ └── IwaraService.kt │ │ │ ├── di │ │ │ ├── LocalModule.kt │ │ │ ├── NetworkModule.kt │ │ │ └── UserModule.kt │ │ │ ├── event │ │ │ └── LoginEvent.kt │ │ │ ├── model │ │ │ ├── comment │ │ │ │ └── CommentList.kt │ │ │ ├── detail │ │ │ │ ├── image │ │ │ │ │ └── ImageDetail.kt │ │ │ │ └── video │ │ │ │ │ ├── VideoDetail.kt │ │ │ │ │ └── VideoLink.kt │ │ │ ├── flag │ │ │ │ ├── FollowResponse.kt │ │ │ │ └── LikeResponse.kt │ │ │ ├── index │ │ │ │ ├── MediaList.kt │ │ │ │ ├── MediaPreview.kt │ │ │ │ └── SubscriptionList.kt │ │ │ ├── session │ │ │ │ ├── Session.kt │ │ │ │ └── SessionManager.kt │ │ │ └── user │ │ │ │ ├── Self.kt │ │ │ │ ├── UserData.kt │ │ │ │ ├── UserImage.kt │ │ │ │ └── UserVideo.kt │ │ │ ├── repo │ │ │ ├── MediaRepo.kt │ │ │ └── UserRepo.kt │ │ │ ├── ui │ │ │ ├── activity │ │ │ │ └── MainActivity.kt │ │ │ ├── local │ │ │ │ └── ScreenOrientation.kt │ │ │ ├── public │ │ │ │ ├── CommentItem.kt │ │ │ │ ├── ExoPlayer.kt │ │ │ │ ├── MediaPreview.kt │ │ │ │ ├── QueryParamSelector.kt │ │ │ │ ├── TabItem.kt │ │ │ │ └── TopBar.kt │ │ │ ├── screen │ │ │ │ ├── image │ │ │ │ │ ├── ImageScreen.kt │ │ │ │ │ └── ImageViewModel.kt │ │ │ │ ├── index │ │ │ │ │ ├── IndexDrawer.kt │ │ │ │ │ ├── IndexScreen.kt │ │ │ │ │ ├── IndexViewModel.kt │ │ │ │ │ └── page │ │ │ │ │ │ ├── ImagePage.kt │ │ │ │ │ │ ├── Subage.kt │ │ │ │ │ │ └── VideoPage.kt │ │ │ │ ├── login │ │ │ │ │ ├── LoginScreen.kt │ │ │ │ │ └── LoginViewModel.kt │ │ │ │ ├── search │ │ │ │ │ ├── SearchScreen.kt │ │ │ │ │ └── SearchViewModel.kt │ │ │ │ ├── splash │ │ │ │ │ ├── SplashScreen.kt │ │ │ │ │ └── SplashViewModel.kt │ │ │ │ ├── user │ │ │ │ │ ├── UserScreen.kt │ │ │ │ │ └── UserViewModel.kt │ │ │ │ └── video │ │ │ │ │ ├── VideoScreen.kt │ │ │ │ │ └── VideoViewModel.kt │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Shape.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── util │ │ │ ├── AutoRetry.kt │ │ │ ├── EventBusExt.kt │ │ │ ├── ModifierEx.kt │ │ │ ├── PageStateExt.kt │ │ │ ├── RecommendSearch.kt │ │ │ ├── ShareUtil.kt │ │ │ └── okhttp │ │ │ ├── CookieJarHelper.kt │ │ │ ├── HeaderInterceptor.kt │ │ │ ├── OkhttpUtil.kt │ │ │ ├── RetryInterceptor.kt │ │ │ └── UserAgentInterceptor.kt │ └── res │ │ ├── drawable │ │ ├── anime_1.jpg │ │ ├── anime_2.jpg │ │ ├── anime_3.jpg │ │ ├── anime_4.jpg │ │ ├── image_icon.xml │ │ ├── index_icon.xml │ │ ├── like_icon.xml │ │ ├── logo.png │ │ ├── play_icon.xml │ │ ├── subscriptions.xml │ │ └── video_icon.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_rounded.png │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── rerere │ └── iwara4a │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.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 | /app/release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Iwara4A 2 | [![GitHub issues](https://img.shields.io/github/issues/jiangdashao/iwara4a)](https://github.com/jiangdashao/iwara4a/issues) 3 | [![GitHub forks](https://img.shields.io/github/forks/jiangdashao/iwara4a)](https://github.com/jiangdashao/iwara4a/network) 4 | [![GitHub stars](https://img.shields.io/github/stars/jiangdashao/iwara4a)](https://github.com/jiangdashao/iwara4a/stargazers) 5 | [![GitHub license](https://img.shields.io/github/license/jiangdashao/iwara4a)](https://github.com/jiangdashao/iwara4a) 6 | 7 | 基于Jetpack Compose开发的 iwara 安卓app, 采用Material Design, 支持夜间模式, 支持绝大多数iwara网站上的功能。 8 | (Iwara的服务器老是无响应,这让我很难办啊) 9 | 10 | ## 📢 前言&QA 11 | 1. 该应用为毕业前学习安卓的练手项目,只要我以后有空,应该会一直维护 12 | 2. 如果你懂Jetpack Compose/Kotlin,欢迎提交PR! 13 | 3. 闲聊群: 935173109 14 | 4. 有能内推的大佬帮帮忙吧,求个Job 15 | 16 | ## ⬇ 下载 17 | 处于早期开发阶段,暂时不提供下载 18 | 19 | ## 🚩 已经实现的功能 20 | * 暴力自动重连,解决iwara土豆服务器总是无响应问题 21 | * 登录/查看个人信息 22 | * 浏览订阅更新列表 23 | * 播放视频 24 | * 查看图片 25 | * 查看评论 26 | * 点赞 27 | * 关注 28 | 29 | ## 🎨 主要技术栈 30 | * MVVM 架构 31 | * Jetpack Compose (构建UI) 32 | * Kotlin Coroutine (协程) 33 | * Okhttp + Jsoup (解析网页) 34 | * Retrofit (访问Restful API) 35 | * EventBus (事件总线) 36 | * Hilt (依赖注入) 37 | * Paging3 (分页加载) 38 | 39 | ## 📜 计划中的功能 40 | * 登录账号 41 | * 浏览 视频/图片/订阅 42 | * 查看个人信息 43 | * 管理收藏夹 44 | * 评论 45 | * 中/英/日 三语言支持 46 | 47 | ## 🔒 不考虑支持的功能 48 | * 论坛 49 | * 同时支持里站和外站 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'dagger.hilt.android.plugin' 6 | } 7 | 8 | android { 9 | compileSdkVersion 30 10 | buildToolsVersion "30.0.3" 11 | 12 | defaultConfig { 13 | applicationId "com.rerere.iwara4a" 14 | minSdkVersion 21 15 | targetSdkVersion 30 16 | versionCode 3 17 | versionName "1.0_alpha03" 18 | 19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 20 | } 21 | 22 | buildTypes { 23 | release { 24 | minifyEnabled false 25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 26 | } 27 | } 28 | compileOptions { 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | kotlinOptions { 33 | jvmTarget = '1.8' 34 | useIR = true 35 | } 36 | buildFeatures { 37 | compose true 38 | } 39 | composeOptions { 40 | kotlinCompilerExtensionVersion compose_version 41 | kotlinCompilerVersion '1.4.31' 42 | } 43 | } 44 | 45 | dependencies { 46 | implementation 'androidx.core:core-ktx:1.5.0' 47 | implementation 'androidx.appcompat:appcompat:1.3.0' 48 | implementation 'com.google.android.material:material:1.3.0' 49 | implementation "androidx.compose.ui:ui:$compose_version" 50 | implementation "androidx.compose.material:material:$compose_version" 51 | implementation "androidx.compose.ui:ui-tooling:$compose_version" 52 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' 53 | implementation 'androidx.activity:activity-compose:1.3.0-beta01' 54 | implementation "androidx.compose.runtime:runtime-livedata:$compose_version" 55 | 56 | implementation 'xyz.doikki.android.dkplayer:dkplayer-java:3.3.2' 57 | implementation 'xyz.doikki.android.dkplayer:dkplayer-ui:3.3.2' 58 | implementation 'xyz.doikki.android.dkplayer:player-exo:3.3.2' 59 | 60 | // ExoPlayer 61 | implementation 'com.google.android.exoplayer:exoplayer:2.14.0' 62 | 63 | // EventBus 64 | implementation 'org.greenrobot:eventbus:3.2.0' 65 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 66 | 67 | // Paging3 68 | def paging_version = "3.0.0" 69 | implementation "androidx.paging:paging-runtime-ktx:$paging_version" 70 | implementation "androidx.paging:paging-compose:1.0.0-alpha10" 71 | 72 | // Hilt 73 | implementation "com.google.dagger:hilt-android:$hilt_version" 74 | kapt "com.google.dagger:hilt-compiler:$hilt_version" 75 | implementation 'androidx.hilt:hilt-navigation-compose:1.0.0-alpha02' 76 | 77 | // Dialog 78 | implementation "io.github.vanpra.compose-material-dialogs:core:0.4.1" 79 | 80 | // Navigation for JetpackCompose 81 | implementation "androidx.navigation:navigation-compose:2.4.0-alpha02" 82 | 83 | // accompanist 84 | def acc_version = "0.11.1" 85 | // Pager 86 | implementation "com.google.accompanist:accompanist-pager:$acc_version" 87 | // Swipe to refresh 88 | implementation "com.google.accompanist:accompanist-swiperefresh:$acc_version" 89 | // 状态栏颜色 90 | implementation "com.google.accompanist:accompanist-systemuicontroller:$acc_version" 91 | // Insets 92 | implementation "com.google.accompanist:accompanist-insets:$acc_version" 93 | // Coil 94 | implementation "io.coil-kt:coil-gif:1.2.1" 95 | implementation "com.google.accompanist:accompanist-coil:$acc_version" 96 | // Flow 97 | implementation "com.google.accompanist:accompanist-flowlayout:$acc_version" 98 | 99 | // Retrofit 100 | implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.2' 101 | implementation 'com.squareup.retrofit2:converter-gson:2.9.0' 102 | implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2' 103 | 104 | // JSOUP 105 | implementation 'org.jsoup:jsoup:1.13.1' 106 | 107 | // 约束布局 108 | implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-alpha07" 109 | 110 | // 图标扩展 111 | implementation "androidx.compose.material:material-icons-extended:$compose_version" 112 | 113 | // Room 114 | def room_version = "2.3.0" 115 | implementation "androidx.room:room-runtime:$room_version" 116 | kapt "androidx.room:room-compiler:$room_version" 117 | implementation "androidx.room:room-ktx:$room_version" 118 | 119 | // Test 120 | testImplementation 'junit:junit:4.+' 121 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 122 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 123 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" 124 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /app/src/androidTest/java/com/rerere/iwara4a/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.rerere.iwara4a", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/AppContext.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | import dagger.hilt.android.HiltAndroidApp 7 | import xyz.doikki.videoplayer.exo.ExoMediaPlayerFactory 8 | import xyz.doikki.videoplayer.player.VideoViewConfig 9 | import xyz.doikki.videoplayer.player.VideoViewManager 10 | 11 | @HiltAndroidApp 12 | class AppContext : Application() { 13 | companion object { 14 | lateinit var instance : Application 15 | } 16 | 17 | override fun onCreate() { 18 | super.onCreate() 19 | instance = this 20 | 21 | // 使用ExoPlayer解码 22 | VideoViewManager.setConfig(VideoViewConfig.newBuilder().setPlayerFactory(ExoMediaPlayerFactory.create()).build()) 23 | } 24 | } 25 | 26 | /** 27 | * 使用顶层函数直接获取 SharedPreference 28 | * 29 | * @param name SharedPreference名字 30 | * @return SharedPreferences实例 31 | */ 32 | fun sharedPreferencesOf(name: String): SharedPreferences = AppContext.instance.getSharedPreferences(name, Context.MODE_PRIVATE) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/IwaraApi.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api 2 | 3 | import androidx.annotation.IntRange 4 | import com.rerere.iwara4a.model.comment.CommentList 5 | import com.rerere.iwara4a.model.detail.image.ImageDetail 6 | import com.rerere.iwara4a.model.detail.video.VideoDetail 7 | import com.rerere.iwara4a.model.flag.FollowResponse 8 | import com.rerere.iwara4a.model.flag.LikeResponse 9 | import com.rerere.iwara4a.model.index.MediaList 10 | import com.rerere.iwara4a.model.index.MediaType 11 | import com.rerere.iwara4a.model.index.SortType 12 | import com.rerere.iwara4a.model.index.SubscriptionList 13 | import com.rerere.iwara4a.model.session.Session 14 | import com.rerere.iwara4a.model.user.Self 15 | import com.rerere.iwara4a.model.user.UserData 16 | 17 | /** 18 | * 提供远程资源API, 通过连接IWARA来获取数据 19 | */ 20 | interface IwaraApi { 21 | /** 22 | * 尝试登录Iwara 23 | * 24 | * @param username 登录用户名 25 | * @param password 登录密码 26 | * @return session cookie 27 | */ 28 | suspend fun login(username: String, password: String): Response 29 | 30 | /** 31 | * 获取基础的个人信息 32 | * 33 | * @param session 登录凭据 34 | * @return 简短的个人信息 35 | */ 36 | suspend fun getSelf(session: Session): Response 37 | 38 | /** 39 | * 获取订阅列表 40 | * 41 | * @param session 登录凭据 42 | * @param page 页数 43 | * @return 订阅列表 44 | */ 45 | suspend fun getSubscriptionList(session: Session, @IntRange(from = 0) page: Int): Response 46 | 47 | /** 48 | * 获取图片页面信息 49 | * 50 | * @param session 登录凭据 51 | * @param imageId 图片ID 52 | * @return 图片页面信息 53 | */ 54 | suspend fun getImagePageDetail(session: Session, imageId: String): Response 55 | 56 | /** 57 | * 获取视频页面信息 58 | * 59 | * @param session 登录凭据 60 | * @param videoId 视频ID 61 | * @return 视频页面信息 62 | */ 63 | suspend fun getVideoPageDetail(session: Session, videoId: String): Response 64 | 65 | /** 66 | * 喜欢某个视频/图片 67 | * 68 | */ 69 | suspend fun like(session: Session, like: Boolean, likeLink: String): Response 70 | 71 | /** 72 | * 关注某人 73 | */ 74 | suspend fun follow(session: Session, follow: Boolean, followLink: String): Response 75 | 76 | /** 77 | * 解析评论列表 78 | * 79 | * @param session 登录凭据 80 | * @param mediaType 媒体类型 81 | * @param mediaId 媒体ID 82 | * @param page 评论页数 83 | * 84 | * @return 评论列表 85 | */ 86 | suspend fun getCommentList(session: Session, mediaType: MediaType, mediaId: String, @IntRange(from = 0) page: Int): Response 87 | 88 | /** 89 | * 获取资源列表 90 | * 91 | * @param session 登录凭据 92 | * @param mediaType 媒体类型 93 | * @param page 页数 94 | * @param sort 排序条件 95 | * @param filter 过滤器 96 | * 97 | * @return 资源列表 98 | */ 99 | suspend fun getMediaList(session: Session, mediaType: MediaType, @IntRange(from = 0) page: Int, sort: SortType, filter: List): Response 100 | 101 | /** 102 | * 加载用户资料 103 | * 104 | * @param session 登录凭据 105 | * @param userId 用户ID 106 | * @return 用户数据 107 | */ 108 | suspend fun getUser(session: Session, userId: String): Response 109 | 110 | /** 111 | * 搜索视频和图片 112 | * 113 | * @param session 登录凭据 114 | * @param query 搜索关键词 115 | * @param page 页数 116 | * @param sort 排序条件 117 | * @param filter 过滤条件 118 | * @return 资源列表 119 | */ 120 | suspend fun search(session: Session, query: String, @IntRange(from = 0) page: Int, sort: SortType, filter: List): Response 121 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/IwaraApiImpl.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api 2 | 3 | import com.rerere.iwara4a.api.service.IwaraParser 4 | import com.rerere.iwara4a.api.service.IwaraService 5 | import com.rerere.iwara4a.model.comment.CommentList 6 | import com.rerere.iwara4a.model.detail.image.ImageDetail 7 | import com.rerere.iwara4a.model.detail.video.VideoDetail 8 | import com.rerere.iwara4a.model.flag.FollowResponse 9 | import com.rerere.iwara4a.model.flag.LikeResponse 10 | import com.rerere.iwara4a.model.index.MediaList 11 | import com.rerere.iwara4a.model.index.MediaType 12 | import com.rerere.iwara4a.model.index.SortType 13 | import com.rerere.iwara4a.model.index.SubscriptionList 14 | import com.rerere.iwara4a.model.session.Session 15 | import com.rerere.iwara4a.model.user.Self 16 | import com.rerere.iwara4a.model.user.UserData 17 | import com.rerere.iwara4a.util.autoRetry 18 | 19 | /** 20 | * IwaraAPI接口的具体实现 21 | * 22 | * 内部持有 iwaraParser 和 iwaraService 两个模块, 根据资源是否可以 23 | * 通过restful api直接访问来选择使用哪个模块获取数据 24 | */ 25 | class IwaraApiImpl( 26 | private val iwaraParser: IwaraParser, 27 | private val iwaraService: IwaraService 28 | ) : IwaraApi { 29 | override suspend fun login(username: String, password: String): Response = 30 | autoRetry(maxRetry = 3) { iwaraParser.login(username, password) } 31 | 32 | override suspend fun getSelf(session: Session): Response = 33 | autoRetry { iwaraParser.getSelf(session) } 34 | 35 | override suspend fun getSubscriptionList( 36 | session: Session, 37 | page: Int 38 | ): Response = autoRetry { iwaraParser.getSubscriptionList(session, page) } 39 | 40 | override suspend fun getImagePageDetail( 41 | session: Session, 42 | imageId: String 43 | ): Response = autoRetry { iwaraParser.getImagePageDetail(session, imageId) } 44 | 45 | override suspend fun getVideoPageDetail( 46 | session: Session, 47 | videoId: String 48 | ): Response { 49 | val response = autoRetry { iwaraParser.getVideoPageDetail(session, videoId) } 50 | return if (response.isSuccess()) { 51 | val link = try { 52 | iwaraService.getVideoInfo(videoId = videoId) 53 | } catch (ex: Exception) { 54 | return Response.failed(ex.javaClass.name) 55 | } 56 | response.read().videoLinks = link 57 | response 58 | } else { 59 | response 60 | } 61 | } 62 | 63 | override suspend fun like( 64 | session: Session, 65 | like: Boolean, 66 | likeLink: String 67 | ): Response = iwaraParser.like(session, like, likeLink) 68 | 69 | override suspend fun follow( 70 | session: Session, 71 | follow: Boolean, 72 | followLink: String 73 | ): Response = iwaraParser.follow(session, follow, followLink) 74 | 75 | override suspend fun getCommentList( 76 | session: Session, 77 | mediaType: MediaType, 78 | mediaId: String, 79 | page: Int 80 | ): Response = autoRetry { 81 | iwaraParser.getCommentList( 82 | session, 83 | mediaType, 84 | mediaId, 85 | page 86 | ) 87 | } 88 | 89 | override suspend fun getMediaList( 90 | session: Session, 91 | mediaType: MediaType, 92 | page: Int, 93 | sort: SortType, 94 | filter: List 95 | ): Response = autoRetry { 96 | iwaraParser.getMediaList( 97 | session, 98 | mediaType, 99 | page, 100 | sort, 101 | filter 102 | ) 103 | } 104 | 105 | override suspend fun getUser(session: Session, userId: String): Response = autoRetry { 106 | iwaraParser.getUser( 107 | session, 108 | userId 109 | ) 110 | } 111 | 112 | override suspend fun search( 113 | session: Session, 114 | query: String, 115 | page: Int, 116 | sort: SortType, 117 | filter: List 118 | ): Response = autoRetry { 119 | iwaraParser.search( 120 | session, 121 | query, 122 | page, 123 | sort, 124 | filter 125 | ) 126 | } 127 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/Response.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api 2 | 3 | sealed class Response( 4 | private val data: T? = null, 5 | private val errorMessage: String? = null 6 | ) { 7 | companion object { 8 | fun success(data: T) = Success(data) 9 | fun failed(errorMessage: String = "null") = Failed(errorMessage) 10 | } 11 | 12 | fun isSuccess() = this is Success 13 | fun isFailed() = this is Failed 14 | 15 | fun read() = data!! 16 | fun errorMessage() = errorMessage!! 17 | 18 | class Success internal constructor(data: T): Response(data = data) 19 | class Failed internal constructor(errorMessage: String): Response(errorMessage = errorMessage) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/paging/CommentSource.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api.paging 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.rerere.iwara4a.model.comment.Comment 6 | import com.rerere.iwara4a.model.index.MediaType 7 | import com.rerere.iwara4a.model.session.SessionManager 8 | import com.rerere.iwara4a.repo.MediaRepo 9 | 10 | class CommentSource( 11 | private val sessionManager: SessionManager, 12 | private val mediaRepo: MediaRepo, 13 | private val mediaType: MediaType, 14 | private val mediaId: String 15 | ): PagingSource() { 16 | override fun getRefreshKey(state: PagingState): Int? { 17 | return 0 18 | } 19 | 20 | override suspend fun load(params: LoadParams): LoadResult { 21 | val page = params.key ?: 0 22 | 23 | val response = mediaRepo.loadComment(sessionManager.session, mediaType, mediaId, page) 24 | 25 | return if(response.isSuccess()){ 26 | LoadResult.Page( 27 | data = response.read().comments, 28 | prevKey = if(page <= 0) null else page - 1, 29 | nextKey = if(response.read().hasNext) page + 1 else null 30 | ) 31 | }else { 32 | LoadResult.Error(Exception(response.errorMessage())) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/paging/MediaSource.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api.paging 2 | 3 | import android.util.Log 4 | import androidx.paging.PagingSource 5 | import androidx.paging.PagingState 6 | import com.rerere.iwara4a.model.index.MediaPreview 7 | import com.rerere.iwara4a.model.index.MediaQueryParam 8 | import com.rerere.iwara4a.model.index.MediaType 9 | import com.rerere.iwara4a.model.session.SessionManager 10 | import com.rerere.iwara4a.repo.MediaRepo 11 | 12 | private const val TAG = "MediaSource" 13 | 14 | class MediaSource( 15 | private val mediaType: MediaType, 16 | private val mediaRepo: MediaRepo, 17 | private val sessionManager: SessionManager, 18 | private val mediaQueryParam: MediaQueryParam 19 | ): PagingSource() { 20 | override fun getRefreshKey(state: PagingState): Int? { 21 | return 0 22 | } 23 | 24 | override suspend fun load(params: LoadParams): LoadResult { 25 | val page = params.key ?: 0 26 | 27 | Log.i(TAG, "load: Trying to load media list: $page") 28 | 29 | val response = mediaRepo.getMediaList(sessionManager.session, mediaType, page, mediaQueryParam.sortType, mediaQueryParam.filters) 30 | return if(response.isSuccess()){ 31 | val data = response.read() 32 | Log.i(TAG, "load: Success load media list (datasize=${data.mediaList.size}, hasNext=${data.hasNext})") 33 | LoadResult.Page( 34 | data = data.mediaList, 35 | prevKey = if(page <= 0) null else page - 1, 36 | nextKey = if(data.hasNext) page + 1 else null 37 | ) 38 | } else { 39 | LoadResult.Error(Exception(response.errorMessage())) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/paging/SearchSource.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api.paging 2 | 3 | import android.util.Log 4 | import androidx.paging.PagingSource 5 | import androidx.paging.PagingState 6 | import com.rerere.iwara4a.model.index.MediaPreview 7 | import com.rerere.iwara4a.model.index.MediaQueryParam 8 | import com.rerere.iwara4a.model.session.SessionManager 9 | import com.rerere.iwara4a.repo.MediaRepo 10 | 11 | private const val TAG = "SearchSource" 12 | 13 | class SearchSource( 14 | private val mediaRepo: MediaRepo, 15 | private val sessionManager: SessionManager, 16 | private val query: String, 17 | private val mediaQueryParam: MediaQueryParam 18 | ) : PagingSource() { 19 | override fun getRefreshKey(state: PagingState): Int? { 20 | return 0 21 | } 22 | 23 | override suspend fun load(params: LoadParams): LoadResult { 24 | val page = params.key ?: 0 25 | 26 | if(query.isBlank()){ 27 | return LoadResult.Page( 28 | data = emptyList(), 29 | prevKey = if (page <= 0) null else page - 1, 30 | nextKey = null 31 | ) 32 | } 33 | 34 | Log.i(TAG, "load: Trying search: $page") 35 | 36 | val response = mediaRepo.search( 37 | sessionManager.session, 38 | query, 39 | page, 40 | mediaQueryParam.sortType, 41 | mediaQueryParam.filters 42 | ) 43 | return if (response.isSuccess()) { 44 | val data = response.read() 45 | Log.i( 46 | TAG, 47 | "load: Success load search list (datasize=${data.mediaList.size}, hasNext=${data.hasNext})" 48 | ) 49 | LoadResult.Page( 50 | data = data.mediaList, 51 | prevKey = if (page <= 0) null else page - 1, 52 | nextKey = if (data.hasNext) page + 1 else null 53 | ) 54 | } else { 55 | LoadResult.Error(Exception(response.errorMessage())) 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/paging/SubscriptionsSource.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api.paging 2 | 3 | import android.util.Log 4 | import androidx.paging.PagingSource 5 | import androidx.paging.PagingState 6 | import com.rerere.iwara4a.model.index.MediaPreview 7 | import com.rerere.iwara4a.model.session.SessionManager 8 | import com.rerere.iwara4a.repo.MediaRepo 9 | 10 | private const val TAG = "SubscriptionsSource" 11 | 12 | class SubscriptionsSource( 13 | private val sessionManager: SessionManager, 14 | private val mediaRepo: MediaRepo 15 | ) : PagingSource() { 16 | override fun getRefreshKey(state: PagingState): Int? { 17 | return 0 18 | } 19 | 20 | override suspend fun load(params: LoadParams): LoadResult { 21 | val page = params.key ?: 0 22 | 23 | Log.i(TAG, "load: trying to load page: $page") 24 | 25 | val response = mediaRepo.getSubscriptionList(sessionManager.session, page) 26 | return if(response.isSuccess()){ 27 | val data = response.read() 28 | Log.i(TAG, "load: success load sub list (datasize=${data.subscriptionList.size}, hasNext=${data.hasNextPage})") 29 | LoadResult.Page( 30 | data = data.subscriptionList, 31 | prevKey = if(page <= 0) null else page - 1, 32 | nextKey = if(data.hasNextPage) page + 1 else null 33 | ) 34 | } else { 35 | LoadResult.Error(Exception(response.errorMessage())) 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/api/service/IwaraService.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.api.service 2 | 3 | import com.rerere.iwara4a.model.detail.video.VideoLink 4 | import retrofit2.http.POST 5 | import retrofit2.http.Path 6 | 7 | /** 8 | * 使用Retrofit直接获取 RESTFUL API 资源 9 | */ 10 | interface IwaraService { 11 | @POST("api/video/{videoId}") 12 | suspend fun getVideoInfo(@Path("videoId") videoId: String): VideoLink 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/di/LocalModule.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.di 2 | 3 | import com.rerere.iwara4a.model.session.SessionManager 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import javax.inject.Singleton 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | object LocalModule { 13 | @Provides 14 | @Singleton 15 | fun provideSessionManager() = SessionManager() 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.di 2 | 3 | import com.rerere.iwara4a.api.IwaraApi 4 | import com.rerere.iwara4a.api.IwaraApiImpl 5 | import com.rerere.iwara4a.api.service.IwaraParser 6 | import com.rerere.iwara4a.api.service.IwaraService 7 | import com.rerere.iwara4a.util.okhttp.CookieJarHelper 8 | import com.rerere.iwara4a.util.okhttp.UserAgentInterceptor 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.components.SingletonComponent 13 | import okhttp3.OkHttpClient 14 | import retrofit2.Retrofit 15 | import retrofit2.converter.gson.GsonConverterFactory 16 | import java.util.concurrent.TimeUnit 17 | import javax.inject.Singleton 18 | 19 | // User Agent 20 | private const val USER_AGENT = 21 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36" 22 | 23 | @Module 24 | @InstallIn(SingletonComponent::class) 25 | object NetworkModule { 26 | private const val TIMEOUT = 3000L 27 | 28 | @Provides 29 | @Singleton 30 | fun provideHttpClient(): OkHttpClient = OkHttpClient.Builder() 31 | .connectTimeout(TIMEOUT, TimeUnit.MILLISECONDS) 32 | .readTimeout(TIMEOUT, TimeUnit.MILLISECONDS) 33 | .callTimeout(TIMEOUT, TimeUnit.MILLISECONDS) 34 | .addInterceptor(UserAgentInterceptor(USER_AGENT)) 35 | //.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.HEADERS }) 36 | .cookieJar(CookieJarHelper()) 37 | .build() 38 | 39 | @Provides 40 | @Singleton 41 | fun provideRetrofitClient(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder() 42 | .client(okHttpClient) 43 | .baseUrl("https://ecchi.iwara.tv/") 44 | .addConverterFactory(GsonConverterFactory.create()) 45 | .build() 46 | 47 | @Provides 48 | @Singleton 49 | fun provideIwaraParser(okHttpClient: OkHttpClient) = IwaraParser(okHttpClient) 50 | 51 | @Provides 52 | @Singleton 53 | fun provideIwaraService(retrofit: Retrofit): IwaraService = retrofit 54 | .create(IwaraService::class.java) 55 | 56 | @Provides 57 | @Singleton 58 | fun provideIwaraApi( 59 | iwaraParser: IwaraParser, 60 | iwaraService: IwaraService 61 | ): IwaraApi = 62 | IwaraApiImpl(iwaraParser, iwaraService) 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/di/UserModule.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.di 2 | 3 | import dagger.Module 4 | import dagger.hilt.InstallIn 5 | import dagger.hilt.components.SingletonComponent 6 | 7 | @Module 8 | @InstallIn(SingletonComponent::class) 9 | object UserModule { 10 | 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/event/LoginEvent.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.event 2 | 3 | import com.rerere.iwara4a.model.session.Session 4 | 5 | /** 6 | * 用户登录事件 7 | */ 8 | data class LoginEvent( 9 | val session: Session 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/comment/CommentList.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.comment 2 | 3 | data class CommentList( 4 | val total: Int, 5 | val page: Int, 6 | val hasNext: Boolean, 7 | val comments: List 8 | ) 9 | 10 | data class Comment( 11 | val authorId: String, 12 | val authorName: String, 13 | val authorPic: String, 14 | val posterType: CommentPosterType, 15 | 16 | val content: String, 17 | val date: String, 18 | 19 | var reply: List 20 | ) 21 | 22 | enum class CommentPosterType { 23 | NORMAL, 24 | SELF, 25 | OWNER 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/detail/image/ImageDetail.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.detail.image 2 | 3 | data class ImageDetail( 4 | val id: String, 5 | val title: String, 6 | val imageLinks: List, 7 | 8 | val authorId: String, 9 | val authorProfilePic: String, 10 | 11 | val watchs: String 12 | ) { 13 | companion object { 14 | val LOADING = ImageDetail( 15 | "", 16 | "", 17 | emptyList(), 18 | "", 19 | "", 20 | "" 21 | ) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/detail/video/VideoDetail.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.detail.video 2 | 3 | data class VideoDetail( 4 | // 视频信息 5 | val id: String, 6 | val title: String, 7 | var videoLinks: VideoLink, 8 | val likes: String, 9 | val watchs: String, 10 | val postDate: String, 11 | val description: String, 12 | 13 | // 视频作者信息 14 | val authorPic: String, 15 | val authorName: String, 16 | val authorId: String, 17 | 18 | // 作者的更多视频 19 | val moreVideo: List, 20 | 21 | // 是否关注 22 | val follow: Boolean, 23 | // 关注链接 24 | val followLink: String, 25 | 26 | // 是否喜欢 27 | var isLike: Boolean, 28 | val likeLink: String 29 | ){ 30 | companion object { 31 | val LOADING = VideoDetail( 32 | "", 33 | "", 34 | VideoLink(), 35 | "", 36 | "", 37 | "", 38 | "", 39 | "", 40 | "", 41 | "", 42 | emptyList(), 43 | true, 44 | "", 45 | true, 46 | "" 47 | ) 48 | } 49 | } 50 | 51 | data class MoreVideo( 52 | val id: String, 53 | val title: String, 54 | val pic: String, 55 | val watchs: String, 56 | val likes: String 57 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/detail/video/VideoLink.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.detail.video 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | class VideoLink : ArrayList(){ 7 | } 8 | 9 | data class VideoLinkItem( 10 | @SerializedName("mime") 11 | val mime: String, 12 | @SerializedName("resolution") 13 | val resolution: String, 14 | @SerializedName("uri") 15 | val uri: String 16 | ){ 17 | fun toLink() = "https:" + unescapeJava(uri).replace("\\/","/") 18 | } 19 | 20 | fun unescapeJava(escaped: String): String { 21 | var escaped = escaped 22 | if (escaped.indexOf("\\u") == -1) return escaped 23 | var processed = "" 24 | var position = escaped.indexOf("\\u") 25 | while (position != -1) { 26 | if (position != 0) processed += escaped.substring(0, position) 27 | val token = escaped.substring(position + 2, position + 6) 28 | escaped = escaped.substring(position + 6) 29 | processed += token.toInt(16).toChar() 30 | position = escaped.indexOf("\\u") 31 | } 32 | processed += escaped 33 | return processed 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/flag/FollowResponse.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.flag 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class FollowResponse( 7 | @SerializedName("contentId") 8 | val contentId: String, 9 | @SerializedName("entityType") 10 | val entityType: String, 11 | @SerializedName("flagName") 12 | val flagName: String, 13 | @SerializedName("flagStatus") 14 | val flagStatus: String, 15 | @SerializedName("flagSuccess") 16 | val flagSuccess: Boolean, 17 | @SerializedName("newLink") 18 | val newLink: String, 19 | @SerializedName("status") 20 | val status: Boolean 21 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/flag/LikeResponse.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.flag 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class LikeResponse( 7 | @SerializedName("contentId") 8 | val contentId: String, 9 | @SerializedName("entityType") 10 | val entityType: String, 11 | @SerializedName("flagName") 12 | val flagName: String, 13 | @SerializedName("flagStatus") 14 | val flagStatus: String, 15 | @SerializedName("flagSuccess") 16 | val flagSuccess: Boolean, 17 | @SerializedName("newLink") 18 | val newLink: String, 19 | @SerializedName("status") 20 | val status: Boolean 21 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/index/MediaList.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.index 2 | 3 | data class MediaList( 4 | val currentPage: Int, 5 | val hasNext: Boolean, 6 | val mediaList: List 7 | ) 8 | 9 | data class MediaQueryParam( 10 | var sortType: SortType, 11 | var filters: List 12 | ) 13 | 14 | enum class SortType(val value: String) { 15 | DATE("date"), 16 | VIEWS("views"), 17 | LIKES("likes") 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/index/MediaPreview.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.index 2 | 3 | /** 4 | * 代表了一个封面预览 5 | */ 6 | data class MediaPreview( 7 | // 标题 8 | val title: String, 9 | // 作者 10 | val author: String, 11 | // 封面 12 | val previewPic: String, 13 | // 喜欢数 14 | val likes: String, 15 | // 播放量 16 | val watchs: String, 17 | // 类型 18 | val type: MediaType, 19 | // 图片ID 20 | val mediaId: String 21 | ) 22 | 23 | enum class MediaType(val value: String) { 24 | VIDEO("videos"), 25 | IMAGE("images") 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/index/SubscriptionList.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.index 2 | 3 | data class SubscriptionList( 4 | val currentPage: Int, 5 | val hasNextPage: Boolean, 6 | val subscriptionList: List 7 | ) 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/session/Session.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.session 2 | 3 | import okhttp3.Cookie 4 | 5 | data class Session( 6 | var key: String, 7 | var value: String 8 | ){ 9 | fun toCookie() = Cookie.Builder() 10 | .name(key) 11 | .value(value) 12 | .domain("iwara.tv") 13 | .build() 14 | 15 | fun isNotEmpty() = key.isNotEmpty() && value.isNotEmpty() 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/session/SessionManager.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.session 2 | 3 | import androidx.core.content.edit 4 | import com.rerere.iwara4a.sharedPreferencesOf 5 | 6 | class SessionManager { 7 | val session: Session by lazy { 8 | val sharedPreferences = sharedPreferencesOf("session") 9 | Session(sharedPreferences.getString("key","")!!, sharedPreferences.getString("value","")!!) 10 | } 11 | 12 | fun update(key: String, value: String) { 13 | session.key = key 14 | session.value = value 15 | sharedPreferencesOf("session").edit { 16 | putString("key", key) 17 | putString("value", value) 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/user/Self.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.user 2 | 3 | data class Self( 4 | val nickname: String, 5 | val profilePic: String 6 | ){ 7 | companion object { 8 | val GUEST = Self("访客", "https://ecchi.iwara.tv/sites/all/themes/main/img/logo.png") 9 | } 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/user/UserData.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.user 2 | 3 | data class UserData( 4 | val userId: String, 5 | val username: String, 6 | val pic: String, 7 | val joinDate: String, 8 | val lastSeen: String, 9 | val about: String 10 | ){ 11 | companion object{ 12 | val LOADING = UserData( 13 | "", 14 | "", 15 | "", 16 | "", 17 | "", 18 | "" 19 | ) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/user/UserImage.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.user 2 | 3 | class UserImage { 4 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/model/user/UserVideo.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.model.user 2 | 3 | class UserVideo { 4 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/repo/MediaRepo.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.repo 2 | 3 | import androidx.annotation.IntRange 4 | import com.rerere.iwara4a.api.IwaraApi 5 | import com.rerere.iwara4a.api.Response 6 | import com.rerere.iwara4a.model.index.MediaList 7 | import com.rerere.iwara4a.model.index.MediaType 8 | import com.rerere.iwara4a.model.index.SortType 9 | import com.rerere.iwara4a.model.index.SubscriptionList 10 | import com.rerere.iwara4a.model.session.Session 11 | import javax.inject.Inject 12 | 13 | class MediaRepo @Inject constructor( 14 | private val iwaraApi: IwaraApi 15 | ) { 16 | suspend fun getSubscriptionList( 17 | session: Session, 18 | @IntRange(from = 0) page: Int 19 | ): Response = iwaraApi.getSubscriptionList(session, page) 20 | 21 | suspend fun getMediaList( 22 | session: Session, 23 | mediaType: MediaType, 24 | page: Int, 25 | sortType: SortType, 26 | filters: List 27 | ) = iwaraApi.getMediaList(session, mediaType, page, sortType, filters) 28 | 29 | suspend fun getImageDetail(session: Session, imageId: String) = 30 | iwaraApi.getImagePageDetail(session, imageId) 31 | 32 | suspend fun getVideoDetail(session: Session, videoId: String) = 33 | iwaraApi.getVideoPageDetail(session, videoId) 34 | 35 | suspend fun like(session: Session, like: Boolean, link: String) = 36 | iwaraApi.like(session, like, link) 37 | 38 | suspend fun follow(session: Session, follow: Boolean, link: String) = 39 | iwaraApi.follow(session, follow, link) 40 | 41 | suspend fun loadComment(session: Session, mediaType: MediaType, mediaId: String, page: Int) = 42 | iwaraApi.getCommentList(session, mediaType, mediaId, page) 43 | 44 | suspend fun search(session: Session, query: String, page: Int, sort: SortType, filter: List): Response = iwaraApi.search( 45 | session, query, page, sort, filter 46 | )/**/ 47 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/repo/UserRepo.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.repo 2 | 3 | import com.rerere.iwara4a.api.IwaraApi 4 | import com.rerere.iwara4a.api.Response 5 | import com.rerere.iwara4a.model.session.Session 6 | import com.rerere.iwara4a.model.user.Self 7 | import com.rerere.iwara4a.model.user.UserData 8 | import javax.inject.Inject 9 | 10 | class UserRepo @Inject constructor( 11 | private val iwaraApi: IwaraApi 12 | ) { 13 | suspend fun login(username: String, password: String): Response = 14 | iwaraApi.login(username, password) 15 | 16 | suspend fun getSelf(session: Session): Response = iwaraApi.getSelf(session) 17 | 18 | suspend fun getUser(session: Session, userId: String): Response = iwaraApi.getUser(session, userId) 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/activity/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.activity 2 | 3 | import android.content.res.Configuration 4 | import android.os.Build 5 | import android.os.Build.VERSION.SDK_INT 6 | import android.os.Bundle 7 | import android.view.Window 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import androidx.annotation.RequiresApi 11 | import androidx.compose.animation.ExperimentalAnimationApi 12 | import androidx.compose.foundation.ExperimentalFoundationApi 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.material.ExperimentalMaterialApi 15 | import androidx.compose.material.MaterialTheme 16 | import androidx.compose.material.primarySurface 17 | import androidx.compose.runtime.* 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.core.view.WindowCompat 21 | import androidx.navigation.NavDeepLink 22 | import androidx.navigation.NavType 23 | import androidx.navigation.compose.NavHost 24 | import androidx.navigation.compose.composable 25 | import androidx.navigation.compose.navArgument 26 | import androidx.navigation.compose.rememberNavController 27 | import coil.ImageLoader 28 | import coil.decode.GifDecoder 29 | import coil.decode.ImageDecoderDecoder 30 | import com.google.accompanist.coil.LocalImageLoader 31 | import com.google.accompanist.insets.ProvideWindowInsets 32 | import com.google.accompanist.pager.ExperimentalPagerApi 33 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 34 | import com.rerere.iwara4a.ui.local.LocalScreenOrientation 35 | import com.rerere.iwara4a.ui.screen.image.ImageScreen 36 | import com.rerere.iwara4a.ui.screen.index.IndexScreen 37 | import com.rerere.iwara4a.ui.screen.login.LoginScreen 38 | import com.rerere.iwara4a.ui.screen.search.SearchScreen 39 | import com.rerere.iwara4a.ui.screen.splash.SplashScreen 40 | import com.rerere.iwara4a.ui.screen.user.UserScreen 41 | import com.rerere.iwara4a.ui.screen.video.VideoScreen 42 | import com.rerere.iwara4a.ui.theme.Iwara4aTheme 43 | import dagger.hilt.android.AndroidEntryPoint 44 | import okhttp3.OkHttpClient 45 | import javax.inject.Inject 46 | 47 | @AndroidEntryPoint 48 | class MainActivity : ComponentActivity() { 49 | @Inject lateinit var okHttpClient: OkHttpClient 50 | var screenOrientation by mutableStateOf(Configuration.ORIENTATION_PORTRAIT) 51 | 52 | @RequiresApi(Build.VERSION_CODES.R) 53 | @ExperimentalFoundationApi 54 | @ExperimentalAnimationApi 55 | @ExperimentalPagerApi 56 | @ExperimentalMaterialApi 57 | override fun onCreate(savedInstanceState: Bundle?) { 58 | super.onCreate(savedInstanceState) 59 | WindowCompat.setDecorFitsSystemWindows(window, false) 60 | requestWindowFeature(Window.FEATURE_NO_TITLE) 61 | 62 | val imageLoader = ImageLoader.Builder(this) 63 | .okHttpClient(okHttpClient) 64 | .componentRegistry { 65 | if (SDK_INT >= 28) { 66 | add(ImageDecoderDecoder(this@MainActivity)) 67 | } else { 68 | add(GifDecoder()) 69 | } 70 | } 71 | .build() 72 | 73 | setContent { 74 | CompositionLocalProvider(LocalImageLoader provides imageLoader, LocalScreenOrientation provides screenOrientation) { 75 | ProvideWindowInsets { 76 | Iwara4aTheme { 77 | val navController = rememberNavController() 78 | 79 | val systemUiController = rememberSystemUiController() 80 | val primaryColor = MaterialTheme.colors.primarySurface 81 | 82 | // set ui color 83 | SideEffect { 84 | systemUiController.setNavigationBarColor(primaryColor) 85 | systemUiController.setStatusBarColor(Color.Transparent, false) 86 | } 87 | 88 | NavHost(modifier = Modifier.fillMaxSize(), navController = navController, startDestination = "splash") { 89 | composable("splash"){ 90 | SplashScreen(navController) 91 | } 92 | 93 | composable("index") { 94 | IndexScreen(navController) 95 | } 96 | 97 | composable("login") { 98 | LoginScreen(navController) 99 | } 100 | 101 | composable("video/{videoId}", arguments = listOf( 102 | navArgument("videoId"){ 103 | type = NavType.StringType 104 | } 105 | ), deepLinks = listOf(NavDeepLink("https://ecchi.iwara.tv/videos/{videoId}"))){ 106 | VideoScreen(navController, it.arguments?.getString("videoId")!!) 107 | } 108 | 109 | composable("image/{imageId}", arguments = listOf( 110 | navArgument("imageId"){ 111 | type = NavType.StringType 112 | } 113 | ), deepLinks = listOf(NavDeepLink("https://ecchi.iwara.tv/images/{imageId}"))){ 114 | ImageScreen(navController, it.arguments?.getString("imageId")!!) 115 | } 116 | 117 | composable("user/{userId}", arguments = listOf( 118 | navArgument("userId"){ 119 | type = NavType.StringType 120 | } 121 | ), deepLinks = listOf(NavDeepLink("https://ecchi.iwara.tv/users/{userId}"))){ 122 | UserScreen(navController, it.arguments?.getString("userId")!!) 123 | } 124 | 125 | composable("search"){ 126 | SearchScreen(navController) 127 | } 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | override fun onConfigurationChanged(newConfig: Configuration) { 136 | super.onConfigurationChanged(newConfig) 137 | screenOrientation = newConfig.orientation 138 | } 139 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/local/ScreenOrientation.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.local 2 | 3 | import android.content.res.Configuration 4 | import androidx.compose.runtime.compositionLocalOf 5 | 6 | val LocalScreenOrientation = compositionLocalOf { Configuration.ORIENTATION_PORTRAIT } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/public/CommentItem.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.public 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.border 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.foundation.shape.CircleShape 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.ContentAlpha 11 | import androidx.compose.material.LocalContentAlpha 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.CompositionLocalProvider 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.draw.clip 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.text.font.FontWeight 20 | import androidx.compose.ui.unit.dp 21 | import androidx.compose.ui.unit.sp 22 | import androidx.navigation.NavController 23 | import com.google.accompanist.coil.rememberCoilPainter 24 | import com.rerere.iwara4a.model.comment.Comment 25 | import com.rerere.iwara4a.model.comment.CommentPosterType 26 | import com.rerere.iwara4a.ui.theme.PINK 27 | import com.rerere.iwara4a.util.noRippleClickable 28 | 29 | @Composable 30 | fun CommentItem(navController: NavController, comment: Comment) { 31 | Box( 32 | modifier = Modifier 33 | .fillMaxWidth() 34 | .border(BorderStroke(0.5.dp, Color.Gray), RoundedCornerShape(6.dp)) 35 | .padding(8.dp) 36 | ) { 37 | Column(Modifier.padding(8.dp)) { 38 | Row( 39 | modifier = Modifier 40 | .fillMaxWidth() 41 | .padding(4.dp), verticalAlignment = Alignment.CenterVertically 42 | ) { 43 | Box( 44 | modifier = Modifier 45 | .size(45.dp) 46 | .clip(CircleShape) 47 | ) { 48 | Image( 49 | modifier = Modifier 50 | .fillMaxSize() 51 | .noRippleClickable { 52 | navController.navigate("user/${comment.authorId}") 53 | }, 54 | painter = rememberCoilPainter(comment.authorPic), 55 | contentDescription = null 56 | ) 57 | } 58 | Column(Modifier.padding(horizontal = 8.dp)) { 59 | Row(verticalAlignment = Alignment.CenterVertically) { 60 | Text( 61 | modifier = Modifier.padding(end = 8.dp).noRippleClickable { 62 | navController.navigate("user/${comment.authorId}") 63 | }, 64 | text = comment.authorName, 65 | fontWeight = FontWeight.Bold, 66 | fontSize = 19.sp 67 | ) 68 | when (comment.posterType) { 69 | CommentPosterType.OWNER -> { 70 | Box( 71 | modifier = Modifier 72 | .clip(RoundedCornerShape(4.dp)) 73 | .background(PINK) 74 | .padding(horizontal = 4.dp, vertical = 2.dp) 75 | ) { 76 | Text(text = "UP主", color = Color.Black, fontSize = 12.sp) 77 | } 78 | } 79 | CommentPosterType.SELF -> { 80 | Box( 81 | modifier = Modifier 82 | .clip(RoundedCornerShape(4.dp)) 83 | .background(Color.Yellow) 84 | .padding(horizontal = 4.dp, vertical = 2.dp) 85 | ) { 86 | Text(text = "你", color = Color.Black, fontSize = 12.sp) 87 | } 88 | } 89 | } 90 | } 91 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 92 | Text(comment.date) 93 | } 94 | } 95 | } 96 | Spacer(modifier = Modifier.height(4.dp)) 97 | Text(modifier = Modifier.padding(horizontal = 4.dp), text = comment.content) 98 | Spacer(modifier = Modifier.height(4.dp)) 99 | Column( 100 | Modifier 101 | .fillMaxWidth() 102 | .padding(start = 8.dp) 103 | ) { 104 | comment.reply.forEach { 105 | CommentItem(navController, it) 106 | } 107 | } 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/public/ExoPlayer.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.public 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.DisposableEffect 6 | import androidx.compose.runtime.LaunchedEffect 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.platform.LocalContext 10 | import androidx.compose.ui.viewinterop.AndroidView 11 | import com.google.android.exoplayer2.MediaItem 12 | import com.google.android.exoplayer2.SimpleExoPlayer 13 | import com.google.android.exoplayer2.ui.StyledPlayerView 14 | 15 | private const val TAG = "ExoPlayerCompose" 16 | 17 | @Composable 18 | fun ExoPlayer(modifier: Modifier = Modifier, videoLink: String) { 19 | val context = LocalContext.current 20 | val exoPlayer = remember { 21 | SimpleExoPlayer.Builder(context).build().apply { 22 | playWhenReady = true 23 | } 24 | } 25 | LaunchedEffect(videoLink) { 26 | if (videoLink.isNotEmpty()) { 27 | Log.i(TAG, "ExoPlayer: Loading Video: $videoLink") 28 | exoPlayer.setMediaItem(MediaItem.fromUri(videoLink)) 29 | exoPlayer.prepare() 30 | } 31 | } 32 | 33 | AndroidView(modifier = modifier, factory = { 34 | StyledPlayerView(it).apply { 35 | player = exoPlayer 36 | } 37 | }) 38 | 39 | DisposableEffect(Unit) { 40 | onDispose { 41 | exoPlayer.release() 42 | Log.i(TAG, "ExoPlayer: Released the Player") 43 | } 44 | } 45 | /* 46 | // TODO: TEST DKVideoPlayer 47 | AndroidView(modifier = modifier, factory = { 48 | VideoView(it).apply { 49 | setUrl(videoLink) 50 | setVideoController(StandardVideoController(it).apply { 51 | addDefaultControlComponent("播放视频", false) 52 | }) 53 | } 54 | }) { 55 | it.setUrl(videoLink) 56 | it.start() 57 | } 58 | 59 | DisposableEffect(Unit) { 60 | onDispose { 61 | 62 | } 63 | } 64 | */ 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/public/MediaPreview.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.public 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.Color 12 | import androidx.compose.ui.layout.ContentScale 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.text.TextStyle 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.unit.dp 18 | import androidx.constraintlayout.compose.ConstraintLayout 19 | import androidx.navigation.NavController 20 | import com.google.accompanist.coil.rememberCoilPainter 21 | import com.rerere.iwara4a.R 22 | import com.rerere.iwara4a.model.index.MediaPreview 23 | import com.rerere.iwara4a.model.index.MediaType 24 | 25 | @Composable 26 | fun MediaPreviewCard(navController: NavController, mediaPreview: MediaPreview) { 27 | val context = LocalContext.current 28 | Card( 29 | modifier = Modifier 30 | .fillMaxWidth() 31 | .padding(horizontal = 16.dp, vertical = 8.dp), 32 | elevation = 4.dp 33 | ) { 34 | Column(modifier = Modifier 35 | .fillMaxWidth() 36 | .clickable { 37 | if (mediaPreview.type == MediaType.VIDEO) { 38 | navController.navigate("video/${mediaPreview.mediaId}") 39 | } else if(mediaPreview.type == MediaType.IMAGE){ 40 | navController.navigate("image/${mediaPreview.mediaId}") 41 | } 42 | } 43 | ) { 44 | Box(modifier = Modifier.height(150.dp), contentAlignment = Alignment.BottomCenter) { 45 | Image( 46 | modifier = Modifier 47 | .fillMaxSize(), 48 | painter = rememberCoilPainter(mediaPreview.previewPic), 49 | contentDescription = null, 50 | contentScale = ContentScale.FillWidth 51 | ) 52 | CompositionLocalProvider( 53 | LocalTextStyle provides TextStyle.Default.copy(color = Color.White), 54 | LocalContentColor provides Color.White 55 | ) { 56 | ConstraintLayout(modifier = Modifier.fillMaxWidth()) { 57 | val (plays, likes, type) = createRefs() 58 | 59 | Row(modifier = Modifier.constrainAs(plays) { 60 | start.linkTo(parent.start, 8.dp) 61 | bottom.linkTo(parent.bottom, 4.dp) 62 | }, verticalAlignment = Alignment.CenterVertically) { 63 | Icon(painterResource(R.drawable.play_icon), null) 64 | Text(text = mediaPreview.watchs) 65 | } 66 | 67 | Row(modifier = Modifier.constrainAs(likes) { 68 | start.linkTo(plays.end, 8.dp) 69 | bottom.linkTo(parent.bottom, 4.dp) 70 | }, verticalAlignment = Alignment.CenterVertically) { 71 | Icon(painterResource(R.drawable.like_icon), null) 72 | Text(text = mediaPreview.likes) 73 | } 74 | 75 | Row(modifier = Modifier.constrainAs(type) { 76 | end.linkTo(parent.end, 8.dp) 77 | bottom.linkTo(parent.bottom, 4.dp) 78 | }, verticalAlignment = Alignment.CenterVertically) { 79 | Text( 80 | text = when (mediaPreview.type) { 81 | MediaType.VIDEO -> "视频" 82 | MediaType.IMAGE -> "图片" 83 | } 84 | ) 85 | } 86 | } 87 | } 88 | } 89 | Column( 90 | Modifier 91 | .fillMaxWidth() 92 | .padding(horizontal = 8.dp) 93 | ) { 94 | Text(text = mediaPreview.title, fontWeight = FontWeight.Bold, maxLines = 1) 95 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { 96 | Text(text = mediaPreview.author, maxLines = 1) 97 | } 98 | } 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/public/QueryParamSelector.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.public 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.remember 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.compose.ui.unit.dp 19 | import com.google.accompanist.flowlayout.FlowRow 20 | import com.rerere.iwara4a.model.index.MediaQueryParam 21 | import com.rerere.iwara4a.model.index.SortType 22 | import com.vanpra.composematerialdialogs.MaterialDialog 23 | import com.vanpra.composematerialdialogs.buttons 24 | import com.vanpra.composematerialdialogs.listItemsSingleChoice 25 | import com.vanpra.composematerialdialogs.title 26 | 27 | @Composable 28 | fun QueryParamSelector( 29 | queryParam: MediaQueryParam, 30 | onChangeSort: (sort: SortType) -> Unit, 31 | onChangeFilters: (filters: List) -> Unit 32 | ) { 33 | val sortDialog = remember { 34 | MaterialDialog() 35 | } 36 | sortDialog.build { 37 | title("选择排序条件") 38 | listItemsSingleChoice( 39 | list = SortType.values().map { it.name }, 40 | onChoiceChange = { 41 | onChangeSort(SortType.values()[it]) 42 | sortDialog.hide() 43 | }, 44 | initialSelection = queryParam.sortType.ordinal, 45 | ) 46 | buttons { 47 | positiveButton("确定"){ 48 | sortDialog.hide() 49 | } 50 | } 51 | } 52 | 53 | FlowRow( 54 | modifier = Modifier 55 | .fillMaxWidth() 56 | .padding(8.dp), 57 | ) { 58 | Row(verticalAlignment = Alignment.CenterVertically) { 59 | Text(text = "排序: ") 60 | Box( 61 | modifier = Modifier 62 | .clickable { sortDialog.show() } 63 | .border(BorderStroke(1.dp, Color.Black), RoundedCornerShape(2.dp)) 64 | .padding(4.dp) 65 | ) { 66 | Text(text = queryParam.sortType.name) 67 | } 68 | } 69 | } 70 | } 71 | 72 | @Preview(showBackground = true) 73 | @Composable 74 | private fun Preview() { 75 | QueryParamSelector( 76 | queryParam = MediaQueryParam(SortType.DATE, listOf("created:2021")), 77 | onChangeSort = { /*TODO*/ }) { 78 | 79 | } 80 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/public/TabItem.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.public 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material.ContentAlpha 8 | import androidx.compose.material.LocalContentAlpha 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.CompositionLocalProvider 13 | import androidx.compose.runtime.rememberCoroutineScope 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.unit.dp 17 | import com.google.accompanist.pager.ExperimentalPagerApi 18 | import com.google.accompanist.pager.PagerState 19 | import com.rerere.iwara4a.util.noRippleClickable 20 | import kotlinx.coroutines.launch 21 | 22 | @Composable 23 | fun TabRow(content: @Composable ()->Unit){ 24 | Row( 25 | modifier = Modifier 26 | .fillMaxWidth() 27 | .height(45.dp), 28 | verticalAlignment = Alignment.CenterVertically 29 | ) { 30 | content() 31 | } 32 | } 33 | 34 | @ExperimentalAnimationApi 35 | @ExperimentalPagerApi 36 | @Composable 37 | fun TabItem(pagerState: PagerState, index: Int, text: String) { 38 | val coroutineScope = rememberCoroutineScope() 39 | val selected = pagerState.currentPage == index 40 | Box( 41 | modifier = Modifier 42 | .noRippleClickable { coroutineScope.launch { pagerState.animateScrollToPage(index) } } 43 | .padding(horizontal = 16.dp, vertical = 4.dp), 44 | contentAlignment = Alignment.Center 45 | ) { 46 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 47 | CompositionLocalProvider(LocalContentAlpha provides if (selected) ContentAlpha.high else ContentAlpha.disabled) { 48 | Text(text = text) 49 | } 50 | 51 | AnimatedVisibility(selected) { 52 | Spacer( 53 | modifier = Modifier 54 | .width(32.dp) 55 | .height(1.dp) 56 | .background(MaterialTheme.colors.onBackground) 57 | ) 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/public/TopBar.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.public 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.CompositionLocalProvider 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.RectangleShape 11 | import androidx.compose.ui.graphics.Shape 12 | import androidx.compose.ui.unit.Dp 13 | import androidx.compose.ui.unit.dp 14 | import com.google.accompanist.insets.statusBarsHeight 15 | import com.google.accompanist.insets.statusBarsPadding 16 | 17 | private val AppBarHorizontalPadding = 4.dp 18 | private val TitleInsetWithoutIcon = Modifier.width(16.dp - AppBarHorizontalPadding) 19 | private val TitleIconModifier = Modifier.fillMaxHeight() 20 | .width(72.dp - AppBarHorizontalPadding) 21 | 22 | @Composable 23 | fun FullScreenTopBar( 24 | title: @Composable () -> Unit, 25 | modifier: Modifier = Modifier, 26 | navigationIcon: @Composable (() -> Unit)? = null, 27 | actions: @Composable RowScope.() -> Unit = {}, 28 | backgroundColor: Color = MaterialTheme.colors.primarySurface, 29 | contentColor: Color = contentColorFor(backgroundColor), 30 | elevation: Dp = AppBarDefaults.TopAppBarElevation 31 | ) { 32 | AppBar( 33 | backgroundColor, 34 | contentColor, 35 | elevation, 36 | AppBarDefaults.ContentPadding, 37 | RectangleShape, 38 | modifier 39 | ) { 40 | if (navigationIcon == null) { 41 | Spacer(TitleInsetWithoutIcon) 42 | } else { 43 | Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { 44 | CompositionLocalProvider( 45 | LocalContentAlpha provides ContentAlpha.high, 46 | content = navigationIcon 47 | ) 48 | } 49 | } 50 | 51 | Row( 52 | Modifier 53 | .fillMaxHeight() 54 | .weight(1f), 55 | verticalAlignment = Alignment.CenterVertically 56 | ) { 57 | ProvideTextStyle(value = MaterialTheme.typography.h6) { 58 | CompositionLocalProvider( 59 | LocalContentAlpha provides ContentAlpha.high, 60 | content = title 61 | ) 62 | } 63 | } 64 | 65 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 66 | Row( 67 | Modifier.fillMaxHeight(), 68 | horizontalArrangement = Arrangement.End, 69 | verticalAlignment = Alignment.CenterVertically, 70 | content = actions 71 | ) 72 | } 73 | } 74 | } 75 | 76 | @Composable 77 | private fun AppBar( 78 | backgroundColor: Color, 79 | contentColor: Color, 80 | elevation: Dp, 81 | contentPadding: PaddingValues, 82 | shape: Shape, 83 | modifier: Modifier = Modifier, 84 | content: @Composable RowScope.() -> Unit 85 | ) { 86 | Surface( 87 | color = backgroundColor, 88 | contentColor = contentColor, 89 | elevation = elevation, 90 | shape = shape, 91 | modifier = modifier.statusBarsHeight(56.dp) 92 | ) { 93 | Row( 94 | Modifier 95 | .fillMaxWidth() 96 | .padding(contentPadding) 97 | .statusBarsPadding(), 98 | horizontalArrangement = Arrangement.Start, 99 | verticalAlignment = Alignment.CenterVertically, 100 | content = content 101 | ) 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/image/ImageScreen.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.image 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.CircleShape 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.ArrowBack 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.layout.ContentScale 15 | import androidx.compose.ui.res.painterResource 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import androidx.hilt.navigation.compose.hiltViewModel 20 | import androidx.navigation.NavController 21 | import com.google.accompanist.coil.rememberCoilPainter 22 | import com.google.accompanist.pager.ExperimentalPagerApi 23 | import com.google.accompanist.pager.HorizontalPager 24 | import com.google.accompanist.pager.rememberPagerState 25 | import com.rerere.iwara4a.R 26 | import com.rerere.iwara4a.model.detail.image.ImageDetail 27 | import com.rerere.iwara4a.ui.public.FullScreenTopBar 28 | import com.rerere.iwara4a.util.noRippleClickable 29 | 30 | @ExperimentalPagerApi 31 | @Composable 32 | fun ImageScreen( 33 | navController: NavController, 34 | imageId: String, 35 | imageViewModel: ImageViewModel = hiltViewModel() 36 | ) { 37 | LaunchedEffect(Unit) { 38 | imageViewModel.load(imageId) 39 | } 40 | Scaffold(topBar = { 41 | FullScreenTopBar( 42 | title = { 43 | Text(text = if (imageViewModel.imageDetail != ImageDetail.LOADING && !imageViewModel.isLoading && !imageViewModel.error) imageViewModel.imageDetail.title else "浏览图片") 44 | }, 45 | navigationIcon = { 46 | IconButton(onClick = { navController.popBackStack() }) { 47 | Icon(Icons.Default.ArrowBack, null) 48 | } 49 | } 50 | ) 51 | }) { 52 | if (imageViewModel.error) { 53 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 54 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 55 | Box(modifier = Modifier 56 | .size(160.dp) 57 | .noRippleClickable { 58 | imageViewModel.load(imageId) 59 | } 60 | .padding(10.dp) 61 | .clip(CircleShape)) { 62 | Image( 63 | modifier = Modifier.fillMaxSize(), 64 | painter = painterResource(R.drawable.anime_3), 65 | contentDescription = null 66 | ) 67 | } 68 | Text(text = "加载失败,点击重试~ (土豆服务器日常)", fontWeight = FontWeight.Bold) 69 | } 70 | } 71 | } else if (imageViewModel.isLoading || imageViewModel.imageDetail == ImageDetail.LOADING) { 72 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 73 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 74 | CircularProgressIndicator() 75 | Text(text = "加载中", fontWeight = FontWeight.Bold) 76 | } 77 | } 78 | } else { 79 | ImagePage(imageViewModel.imageDetail) 80 | } 81 | } 82 | } 83 | 84 | @ExperimentalPagerApi 85 | @Composable 86 | private fun ImagePage(imageDetail: ImageDetail) { 87 | val pagerState = rememberPagerState(pageCount = imageDetail.imageLinks.size, initialPage = 0, infiniteLoop = true) 88 | Column(Modifier.fillMaxSize()) { 89 | HorizontalPager(state = pagerState) { 90 | Box(modifier = Modifier 91 | .fillMaxWidth() 92 | .height(200.dp) 93 | ){ 94 | Image(modifier = Modifier.fillMaxWidth(), painter = rememberCoilPainter(imageDetail.imageLinks[pagerState.currentPage]), contentDescription = null, contentScale = ContentScale.FillWidth) 95 | } 96 | } 97 | Card(modifier = Modifier 98 | .fillMaxWidth() 99 | .height(120.dp) 100 | .padding(16.dp)) { 101 | Row(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { 102 | Box( 103 | modifier = Modifier 104 | .size(70.dp) 105 | .clip(CircleShape) 106 | ) { 107 | Image(modifier = Modifier.fillMaxSize(), painter = rememberCoilPainter(imageDetail.authorProfilePic), contentDescription = null) 108 | } 109 | 110 | Text(modifier = Modifier.padding(horizontal = 16.dp), text = imageDetail.authorId, fontWeight = FontWeight.Bold, fontSize = 25.sp) 111 | } 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/image/ImageViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.image 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.rerere.iwara4a.model.detail.image.ImageDetail 9 | import com.rerere.iwara4a.model.session.SessionManager 10 | import com.rerere.iwara4a.repo.MediaRepo 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.launch 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class ImageViewModel @Inject constructor( 17 | private val sessionManager: SessionManager, 18 | private val mediaRepo: MediaRepo 19 | ): ViewModel() { 20 | var imageDetail by mutableStateOf(ImageDetail.LOADING) 21 | var isLoading by mutableStateOf(false) 22 | var error by mutableStateOf(false) 23 | 24 | fun load(imageId: String) = viewModelScope.launch { 25 | isLoading = true 26 | error = false 27 | 28 | val response = mediaRepo.getImageDetail(sessionManager.session, imageId) 29 | if(response.isSuccess()){ 30 | imageDetail = response.read() 31 | }else { 32 | error = true 33 | } 34 | 35 | isLoading = false 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/index/IndexDrawer.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.index 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.shape.CircleShape 8 | import androidx.compose.material.* 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.Refresh 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.CompositionLocalProvider 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.draw.clip 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.text.font.FontWeight 18 | import androidx.compose.ui.unit.dp 19 | import androidx.navigation.NavController 20 | import com.google.accompanist.coil.rememberCoilPainter 21 | import com.google.accompanist.insets.statusBarsHeight 22 | 23 | @Composable 24 | fun IndexDrawer(navController: NavController, indexViewModel: IndexViewModel) { 25 | fun isLoading() = indexViewModel.loadingSelf 26 | Column( 27 | modifier = Modifier 28 | .fillMaxSize() 29 | ) { 30 | // Profile 31 | Surface( 32 | modifier = Modifier 33 | .fillMaxWidth() 34 | .statusBarsHeight(185.dp), 35 | elevation = 8.dp, 36 | color = MaterialTheme.colors.primaryVariant 37 | ) { 38 | Column( 39 | modifier = Modifier 40 | .fillMaxWidth(), 41 | verticalArrangement = Arrangement.Bottom 42 | ) { 43 | // Profile Pic 44 | Box(modifier = Modifier.padding(horizontal = 32.dp)){ 45 | Box( 46 | modifier = Modifier 47 | .size(70.dp) 48 | .clip(CircleShape) 49 | .background(Color.LightGray) 50 | .clickable { 51 | navController.navigate("login") 52 | } 53 | ) { 54 | val painter = rememberCoilPainter(indexViewModel.self.profilePic) 55 | Image(modifier = Modifier.fillMaxSize(), painter = painter, contentDescription = null) 56 | } 57 | } 58 | 59 | // Profile Info 60 | Column(modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)) { 61 | // UserName 62 | Text( 63 | text = if(isLoading()) "加载中" else indexViewModel.self.nickname, 64 | style = MaterialTheme.typography.h5, 65 | fontWeight = FontWeight.Bold 66 | ) 67 | // Email 68 | Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { 69 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 70 | Text(modifier = Modifier.weight(1f), text = indexViewModel.email) 71 | } 72 | IconButton(modifier = Modifier.size(25.dp), onClick = { indexViewModel.refreshSelf() }) { 73 | Icon(modifier = Modifier.size(25.dp), imageVector = Icons.Default.Refresh, contentDescription = "刷新个人信息") 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | // Navigation List 81 | Column( 82 | modifier = Modifier 83 | .fillMaxWidth() 84 | .weight(1f) 85 | ) { 86 | Surface(Modifier.fillMaxSize()) { 87 | 88 | } 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/index/IndexScreen.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.index 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.material.* 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.Search 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.rememberCoroutineScope 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.clip 17 | import androidx.compose.ui.res.painterResource 18 | import androidx.compose.ui.res.stringResource 19 | import androidx.compose.ui.unit.dp 20 | import androidx.hilt.navigation.compose.hiltViewModel 21 | import androidx.navigation.NavController 22 | import com.google.accompanist.coil.rememberCoilPainter 23 | import com.google.accompanist.insets.navigationBarsPadding 24 | import com.google.accompanist.pager.ExperimentalPagerApi 25 | import com.google.accompanist.pager.HorizontalPager 26 | import com.google.accompanist.pager.PagerState 27 | import com.google.accompanist.pager.rememberPagerState 28 | import com.rerere.iwara4a.R 29 | import com.rerere.iwara4a.ui.public.FullScreenTopBar 30 | import com.rerere.iwara4a.ui.screen.index.page.ImageListPage 31 | import com.rerere.iwara4a.ui.screen.index.page.SubPage 32 | import com.rerere.iwara4a.ui.screen.index.page.VideoListPage 33 | import com.rerere.iwara4a.util.currentVisualPage 34 | import kotlinx.coroutines.launch 35 | 36 | @ExperimentalFoundationApi 37 | @ExperimentalPagerApi 38 | @ExperimentalMaterialApi 39 | @Composable 40 | fun IndexScreen(navController: NavController, indexViewModel: IndexViewModel = hiltViewModel()) { 41 | val pagerState = rememberPagerState(pageCount = 3, initialPage = 1) 42 | val scaffoldState = rememberScaffoldState() 43 | Scaffold( 44 | scaffoldState = scaffoldState, 45 | topBar = { TopBar(scaffoldState, indexViewModel, navController) }, 46 | bottomBar = { 47 | BottomBar(pagerState = pagerState) 48 | }, 49 | drawerContent = { 50 | IndexDrawer(navController, indexViewModel) 51 | } 52 | ) { 53 | HorizontalPager( 54 | modifier = Modifier 55 | .fillMaxSize() 56 | .padding(it), 57 | state = pagerState 58 | ) { 59 | when (it) { 60 | 0 -> { 61 | VideoListPage(navController, indexViewModel) 62 | } 63 | 1 -> { 64 | SubPage(navController, indexViewModel) 65 | } 66 | 2 -> { 67 | ImageListPage(navController, indexViewModel) 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | @Composable 75 | private fun TopBar(scaffoldState: ScaffoldState, indexViewModel: IndexViewModel, navController: NavController) { 76 | val coroutineScope = rememberCoroutineScope() 77 | FullScreenTopBar( 78 | title = { 79 | Text(text = stringResource(R.string.app_name)) 80 | }, 81 | navigationIcon = { 82 | IconButton(onClick = { 83 | coroutineScope.launch { 84 | scaffoldState.drawerState.open() 85 | } 86 | }) { 87 | Box(modifier = Modifier 88 | .size(30.dp) 89 | .clip(CircleShape)) { 90 | Image( 91 | modifier = Modifier.fillMaxSize(), 92 | painter = rememberCoilPainter(indexViewModel.self.profilePic), 93 | contentDescription = null 94 | ) 95 | } 96 | } 97 | }, 98 | actions = { 99 | IconButton(onClick = { navController.navigate("search") }) { 100 | Icon(Icons.Default.Search, null ) 101 | } 102 | } 103 | ) 104 | } 105 | 106 | @ExperimentalPagerApi 107 | @Composable 108 | private fun BottomBar(pagerState: PagerState) { 109 | val coroutineScope = rememberCoroutineScope() 110 | BottomNavigation(modifier = Modifier.navigationBarsPadding()) { 111 | BottomNavigationItem( 112 | selected = pagerState.currentVisualPage == 0, 113 | onClick = { 114 | coroutineScope.launch { pagerState.animateScrollToPage(0) } 115 | }, 116 | icon = { 117 | Icon(painter = painterResource(R.drawable.video_icon), contentDescription = null) 118 | }, 119 | label = { 120 | Text(text = "视频") 121 | } 122 | ) 123 | BottomNavigationItem( 124 | selected = pagerState.currentVisualPage == 1, 125 | onClick = { 126 | coroutineScope.launch { pagerState.animateScrollToPage(1) } 127 | }, 128 | icon = { 129 | Icon(painter = painterResource(R.drawable.subscriptions), contentDescription = null) 130 | }, 131 | label = { 132 | Text(text = "关注") 133 | } 134 | ) 135 | BottomNavigationItem( 136 | selected = pagerState.currentVisualPage == 2, 137 | onClick = { 138 | coroutineScope.launch { pagerState.animateScrollToPage(2) } 139 | }, 140 | icon = { 141 | Icon(painter = painterResource(R.drawable.image_icon), contentDescription = null) 142 | }, 143 | label = { 144 | Text(text = "图片") 145 | } 146 | ) 147 | } 148 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/index/IndexViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.index 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.paging.Pager 9 | import androidx.paging.PagingConfig 10 | import androidx.paging.cachedIn 11 | import com.rerere.iwara4a.api.paging.MediaSource 12 | import com.rerere.iwara4a.api.paging.SubscriptionsSource 13 | import com.rerere.iwara4a.event.LoginEvent 14 | import com.rerere.iwara4a.model.index.MediaQueryParam 15 | import com.rerere.iwara4a.model.index.MediaType 16 | import com.rerere.iwara4a.model.index.SortType 17 | import com.rerere.iwara4a.model.session.SessionManager 18 | import com.rerere.iwara4a.model.user.Self 19 | import com.rerere.iwara4a.repo.MediaRepo 20 | import com.rerere.iwara4a.repo.UserRepo 21 | import com.rerere.iwara4a.sharedPreferencesOf 22 | import com.rerere.iwara4a.util.registerListener 23 | import com.rerere.iwara4a.util.unregisterListener 24 | import dagger.hilt.android.lifecycle.HiltViewModel 25 | import kotlinx.coroutines.launch 26 | import org.greenrobot.eventbus.Subscribe 27 | import org.greenrobot.eventbus.ThreadMode 28 | import javax.inject.Inject 29 | 30 | @HiltViewModel 31 | class IndexViewModel @Inject constructor( 32 | private val userRepo: UserRepo, 33 | private val mediaRepo: MediaRepo, 34 | private val sessionManager: SessionManager, 35 | ) : ViewModel() { 36 | var self by mutableStateOf(Self.GUEST) 37 | var email by mutableStateOf("") 38 | var loadingSelf by mutableStateOf(false) 39 | 40 | // Pager: 视频列表 41 | var videoQueryParam: MediaQueryParam by mutableStateOf(MediaQueryParam(SortType.DATE, emptyList())) 42 | val videoPager by lazy { 43 | Pager(config = PagingConfig(pageSize = 32, initialLoadSize = 32)) 44 | { 45 | MediaSource( 46 | MediaType.VIDEO, 47 | mediaRepo, 48 | sessionManager, 49 | videoQueryParam 50 | ) 51 | }.flow.cachedIn(viewModelScope) 52 | } 53 | 54 | // Pager: 订阅列表 55 | val subscriptionPager by lazy { 56 | Pager( 57 | config = PagingConfig( 58 | pageSize = 32, 59 | initialLoadSize = 32, 60 | prefetchDistance = 8 61 | ) 62 | ) { 63 | SubscriptionsSource( 64 | sessionManager, 65 | mediaRepo 66 | ) 67 | }.flow.cachedIn(viewModelScope) 68 | } 69 | 70 | // 图片列表 71 | var imageQueryParam: MediaQueryParam by mutableStateOf(MediaQueryParam(SortType.DATE, emptyList())) 72 | val imagePager by lazy { 73 | Pager(config = PagingConfig(pageSize = 32, initialLoadSize = 32, prefetchDistance = 8)) 74 | { 75 | MediaSource( 76 | MediaType.IMAGE, 77 | mediaRepo, 78 | sessionManager, 79 | imageQueryParam 80 | ) 81 | }.flow.cachedIn(viewModelScope) 82 | } 83 | 84 | init { 85 | registerListener() 86 | refreshSelf() 87 | } 88 | 89 | override fun onCleared() { 90 | unregisterListener() 91 | } 92 | 93 | fun refreshSelf() = viewModelScope.launch { 94 | loadingSelf = true 95 | email = sharedPreferencesOf("session").getString("username","请先登录你的账号吧")!! 96 | val response = userRepo.getSelf(sessionManager.session) 97 | if (response.isSuccess()) { 98 | self = response.read() 99 | } 100 | loadingSelf = false 101 | } 102 | 103 | @Subscribe(threadMode = ThreadMode.MAIN) 104 | fun onLogin(loginEvent: LoginEvent) { 105 | refreshSelf() 106 | } 107 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/index/page/ImagePage.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.index.page 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.shape.CircleShape 7 | import androidx.compose.material.CircularProgressIndicator 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.unit.dp 17 | import androidx.navigation.NavController 18 | import androidx.paging.LoadState 19 | import androidx.paging.compose.collectAsLazyPagingItems 20 | import androidx.paging.compose.items 21 | import com.google.accompanist.swiperefresh.SwipeRefresh 22 | import com.google.accompanist.swiperefresh.SwipeRefreshIndicator 23 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 24 | import com.rerere.iwara4a.R 25 | import com.rerere.iwara4a.ui.public.MediaPreviewCard 26 | import com.rerere.iwara4a.ui.public.QueryParamSelector 27 | import com.rerere.iwara4a.ui.screen.index.IndexViewModel 28 | import com.rerere.iwara4a.util.noRippleClickable 29 | 30 | @Composable 31 | fun ImageListPage(navController: NavController, indexViewModel: IndexViewModel){ 32 | val imageList = indexViewModel.imagePager.collectAsLazyPagingItems() 33 | val swipeRefreshState = rememberSwipeRefreshState(isRefreshing = imageList.loadState.refresh == LoadState.Loading) 34 | 35 | Box(modifier = Modifier.fillMaxSize()) { 36 | if (imageList.loadState.refresh is LoadState.Error) { 37 | Box( 38 | modifier = Modifier 39 | .fillMaxSize() 40 | .noRippleClickable { 41 | imageList.retry() 42 | }, 43 | contentAlignment = Alignment.Center 44 | ) { 45 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 46 | Box(modifier = Modifier 47 | .size(160.dp) 48 | .padding(10.dp) 49 | .clip(CircleShape)) { 50 | Image( 51 | modifier = Modifier.fillMaxSize(), 52 | painter = painterResource(R.drawable.anime_1), 53 | contentDescription = null 54 | ) 55 | } 56 | Text(text = "加载失败,点击重试~ (土豆服务器日常)", fontWeight = FontWeight.Bold) 57 | } 58 | } 59 | } else { 60 | SwipeRefresh(state = swipeRefreshState, onRefresh = { imageList.refresh() }, indicator = {s, trigger -> 61 | SwipeRefreshIndicator(s, trigger, contentColor = MaterialTheme.colors.onSurface) 62 | } ) { 63 | LazyColumn(modifier = Modifier.fillMaxSize()) { 64 | item { 65 | QueryParamSelector( 66 | queryParam = indexViewModel.imageQueryParam, 67 | onChangeSort = { 68 | indexViewModel.imageQueryParam.sortType = it 69 | imageList.refresh() 70 | }, 71 | onChangeFilters = { 72 | indexViewModel.imageQueryParam.filters = it 73 | imageList.refresh() 74 | } 75 | ) 76 | } 77 | 78 | items(imageList) { 79 | MediaPreviewCard(navController, it!!) 80 | } 81 | 82 | when (imageList.loadState.append) { 83 | LoadState.Loading -> { 84 | item { 85 | Row( 86 | modifier = Modifier 87 | .fillMaxSize() 88 | .padding(8.dp), 89 | horizontalArrangement = Arrangement.Center, 90 | verticalAlignment = Alignment.CenterVertically 91 | ) { 92 | CircularProgressIndicator(Modifier.size(30.dp)) 93 | Text( 94 | modifier = Modifier.padding(horizontal = 16.dp), 95 | text = "加载中..." 96 | ) 97 | } 98 | } 99 | } 100 | is LoadState.Error -> { 101 | item { 102 | Row( 103 | modifier = Modifier 104 | .fillMaxSize() 105 | .noRippleClickable { imageList.retry() } 106 | .padding(8.dp), 107 | horizontalArrangement = Arrangement.Center, 108 | verticalAlignment = Alignment.CenterVertically 109 | ) { 110 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 111 | Box( 112 | modifier = Modifier 113 | .size(140.dp) 114 | .padding(10.dp) 115 | .clip(CircleShape) 116 | ) { 117 | Image( 118 | modifier = Modifier.fillMaxSize(), 119 | painter = painterResource(R.drawable.anime_2), 120 | contentDescription = null 121 | ) 122 | } 123 | Text( 124 | modifier = Modifier.padding(horizontal = 16.dp), 125 | text = "加载失败: ${(imageList.loadState.append as LoadState.Error).error.message}" 126 | ) 127 | Text(text = "点击重试") 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/index/page/Subage.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.index.page 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.shape.CircleShape 8 | import androidx.compose.material.CircularProgressIndicator 9 | import androidx.compose.material.MaterialTheme 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.clip 15 | import androidx.compose.ui.res.painterResource 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.unit.dp 18 | import androidx.navigation.NavController 19 | import androidx.paging.LoadState 20 | import androidx.paging.compose.collectAsLazyPagingItems 21 | import androidx.paging.compose.items 22 | import com.google.accompanist.swiperefresh.SwipeRefresh 23 | import com.google.accompanist.swiperefresh.SwipeRefreshIndicator 24 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 25 | import com.rerere.iwara4a.R 26 | import com.rerere.iwara4a.ui.public.MediaPreviewCard 27 | import com.rerere.iwara4a.ui.screen.index.IndexViewModel 28 | import com.rerere.iwara4a.util.noRippleClickable 29 | 30 | @ExperimentalFoundationApi 31 | @Composable 32 | fun SubPage(navController: NavController, indexViewModel: IndexViewModel) { 33 | val subscriptionList = indexViewModel.subscriptionPager.collectAsLazyPagingItems() 34 | val swipeRefreshState = rememberSwipeRefreshState(isRefreshing = subscriptionList.loadState.refresh == LoadState.Loading) 35 | 36 | Box(modifier = Modifier.fillMaxSize()) { 37 | if (subscriptionList.loadState.refresh is LoadState.Error) { 38 | Box( 39 | modifier = Modifier 40 | .fillMaxSize() 41 | .noRippleClickable { 42 | subscriptionList.retry() 43 | }, 44 | contentAlignment = Alignment.Center 45 | ) { 46 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 47 | Box(modifier = Modifier 48 | .size(160.dp) 49 | .padding(10.dp) 50 | .clip(CircleShape)) { 51 | Image( 52 | modifier = Modifier.fillMaxSize(), 53 | painter = painterResource(R.drawable.anime_1), 54 | contentDescription = null 55 | ) 56 | } 57 | Text(text = "加载失败,点击重试~ (土豆服务器日常)", fontWeight = FontWeight.Bold) 58 | } 59 | } 60 | } else { 61 | SwipeRefresh(state = swipeRefreshState, onRefresh = { subscriptionList.refresh() }, indicator = {s, trigger -> 62 | SwipeRefreshIndicator(s, trigger, contentColor = MaterialTheme.colors.onSurface) 63 | } ) { 64 | LazyColumn(modifier = Modifier.fillMaxSize()) { 65 | items(subscriptionList) { 66 | MediaPreviewCard(navController, it!!) 67 | } 68 | 69 | when (subscriptionList.loadState.append) { 70 | LoadState.Loading -> { 71 | item { 72 | Row( 73 | modifier = Modifier 74 | .fillMaxSize() 75 | .padding(8.dp), 76 | horizontalArrangement = Arrangement.Center, 77 | verticalAlignment = Alignment.CenterVertically 78 | ) { 79 | CircularProgressIndicator(Modifier.size(30.dp)) 80 | Text( 81 | modifier = Modifier.padding(horizontal = 16.dp), 82 | text = "加载中..." 83 | ) 84 | } 85 | } 86 | } 87 | is LoadState.Error -> { 88 | item { 89 | Row( 90 | modifier = Modifier 91 | .fillMaxSize() 92 | .noRippleClickable { subscriptionList.retry() } 93 | .padding(8.dp), 94 | horizontalArrangement = Arrangement.Center, 95 | verticalAlignment = Alignment.CenterVertically 96 | ) { 97 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 98 | Box( 99 | modifier = Modifier 100 | .size(140.dp) 101 | .padding(10.dp) 102 | .clip(CircleShape) 103 | ) { 104 | Image( 105 | modifier = Modifier.fillMaxSize(), 106 | painter = painterResource(R.drawable.anime_2), 107 | contentDescription = null 108 | ) 109 | } 110 | Text( 111 | modifier = Modifier.padding(horizontal = 16.dp), 112 | text = "加载失败: ${(subscriptionList.loadState.append as LoadState.Error).error.message}" 113 | ) 114 | Text(text = "点击重试") 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/index/page/VideoPage.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.index.page 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.shape.CircleShape 7 | import androidx.compose.material.CircularProgressIndicator 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.res.painterResource 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.unit.dp 17 | import androidx.navigation.NavController 18 | import androidx.paging.LoadState 19 | import androidx.paging.compose.collectAsLazyPagingItems 20 | import androidx.paging.compose.items 21 | import com.google.accompanist.swiperefresh.SwipeRefresh 22 | import com.google.accompanist.swiperefresh.SwipeRefreshIndicator 23 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 24 | import com.rerere.iwara4a.R 25 | import com.rerere.iwara4a.ui.public.MediaPreviewCard 26 | import com.rerere.iwara4a.ui.public.QueryParamSelector 27 | import com.rerere.iwara4a.ui.screen.index.IndexViewModel 28 | import com.rerere.iwara4a.util.noRippleClickable 29 | 30 | @Composable 31 | fun VideoListPage(navController: NavController, indexViewModel: IndexViewModel) { 32 | val videoList = indexViewModel.videoPager.collectAsLazyPagingItems() 33 | val swipeRefreshState = 34 | rememberSwipeRefreshState(isRefreshing = videoList.loadState.refresh == LoadState.Loading) 35 | 36 | Box(modifier = Modifier.fillMaxSize()) { 37 | if (videoList.loadState.refresh is LoadState.Error) { 38 | Box( 39 | modifier = Modifier 40 | .fillMaxSize() 41 | .noRippleClickable { 42 | videoList.retry() 43 | }, 44 | contentAlignment = Alignment.Center 45 | ) { 46 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 47 | Box( 48 | modifier = Modifier 49 | .size(160.dp) 50 | .padding(10.dp) 51 | .clip(CircleShape) 52 | ) { 53 | Image( 54 | modifier = Modifier.fillMaxSize(), 55 | painter = painterResource(R.drawable.anime_1), 56 | contentDescription = null 57 | ) 58 | } 59 | Text(text = "加载失败,点击重试~ (土豆服务器日常)", fontWeight = FontWeight.Bold) 60 | } 61 | } 62 | } else { 63 | SwipeRefresh( 64 | state = swipeRefreshState, 65 | onRefresh = { videoList.refresh() }, 66 | indicator = { s, trigger -> 67 | SwipeRefreshIndicator(s, trigger, contentColor = MaterialTheme.colors.onSurface) 68 | }) { 69 | LazyColumn(modifier = Modifier.fillMaxSize()) { 70 | item { 71 | QueryParamSelector( 72 | queryParam = indexViewModel.videoQueryParam, 73 | onChangeSort = { 74 | indexViewModel.videoQueryParam.sortType = it 75 | videoList.refresh() 76 | }, 77 | onChangeFilters = { 78 | indexViewModel.videoQueryParam.filters = it 79 | videoList.refresh() 80 | } 81 | ) 82 | } 83 | 84 | items(videoList) { 85 | MediaPreviewCard(navController, it!!) 86 | } 87 | 88 | when (videoList.loadState.append) { 89 | LoadState.Loading -> { 90 | item { 91 | Row( 92 | modifier = Modifier 93 | .fillMaxSize() 94 | .padding(8.dp), 95 | horizontalArrangement = Arrangement.Center, 96 | verticalAlignment = Alignment.CenterVertically 97 | ) { 98 | CircularProgressIndicator(Modifier.size(30.dp)) 99 | Text( 100 | modifier = Modifier.padding(horizontal = 16.dp), 101 | text = "加载中..." 102 | ) 103 | } 104 | } 105 | } 106 | is LoadState.Error -> { 107 | item { 108 | Row( 109 | modifier = Modifier 110 | .fillMaxSize() 111 | .noRippleClickable { videoList.retry() } 112 | .padding(8.dp), 113 | horizontalArrangement = Arrangement.Center, 114 | verticalAlignment = Alignment.CenterVertically 115 | ) { 116 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 117 | Box( 118 | modifier = Modifier 119 | .size(140.dp) 120 | .padding(10.dp) 121 | .clip(CircleShape) 122 | ) { 123 | Image( 124 | modifier = Modifier.fillMaxSize(), 125 | painter = painterResource(R.drawable.anime_2), 126 | contentDescription = null 127 | ) 128 | } 129 | Text( 130 | modifier = Modifier.padding(horizontal = 16.dp), 131 | text = "加载失败: ${(videoList.loadState.append as LoadState.Error).error.message}" 132 | ) 133 | Text(text = "点击重试") 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/login/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.login 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.widget.Toast 6 | import androidx.compose.animation.Crossfade 7 | import androidx.compose.animation.ExperimentalAnimationApi 8 | import androidx.compose.foundation.Image 9 | import androidx.compose.foundation.layout.* 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.foundation.text.KeyboardOptions 12 | import androidx.compose.material.* 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.filled.Visibility 15 | import androidx.compose.material.icons.filled.VisibilityOff 16 | import androidx.compose.runtime.* 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.clip 20 | import androidx.compose.ui.platform.LocalContext 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.text.input.KeyboardType 23 | import androidx.compose.ui.text.input.PasswordVisualTransformation 24 | import androidx.compose.ui.text.input.VisualTransformation 25 | import androidx.compose.ui.unit.dp 26 | import androidx.hilt.navigation.compose.hiltViewModel 27 | import androidx.navigation.NavController 28 | import com.google.accompanist.insets.navigationBarsWithImePadding 29 | import com.rerere.iwara4a.R 30 | import com.rerere.iwara4a.ui.public.FullScreenTopBar 31 | import com.vanpra.composematerialdialogs.* 32 | 33 | @ExperimentalAnimationApi 34 | @Composable 35 | fun LoginScreen(navController: NavController, loginViewModel: LoginViewModel = hiltViewModel()) { 36 | Scaffold( 37 | topBar = { 38 | TopBar(navController) 39 | } 40 | ) { 41 | Box( 42 | modifier = Modifier 43 | .fillMaxSize() 44 | .padding(32.dp) 45 | .navigationBarsWithImePadding(), 46 | contentAlignment = Alignment.Center 47 | ) { 48 | Content(loginViewModel, navController) 49 | } 50 | } 51 | } 52 | 53 | @Composable 54 | private fun Content(loginViewModel: LoginViewModel, navController: NavController) { 55 | val context = LocalContext.current 56 | var showPassword by remember { 57 | mutableStateOf(false) 58 | } 59 | 60 | // 登录进度对话框 61 | val progressDialog = remember { 62 | MaterialDialog(onCloseRequest = {}) 63 | } 64 | progressDialog.build { 65 | iconTitle( 66 | text = "登录中", 67 | icon = { CircularProgressIndicator(Modifier.size(30.dp)) } 68 | ) 69 | message("请稍等片刻") 70 | } 71 | // 登录失败 72 | val failedDialog = remember { 73 | MaterialDialog() 74 | } 75 | failedDialog.build { 76 | title("登录失败") 77 | message("请检查你的用户名和密码是否正确,如果确定准确,请再次重试登录") 78 | message("错误内容: ${loginViewModel.errorContent}") 79 | message("(别忘记挂梯子!)") 80 | buttons { 81 | button("好的") { 82 | failedDialog.hide() 83 | } 84 | } 85 | } 86 | 87 | // 内容 88 | Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { 89 | // LOGO 90 | Box( 91 | modifier = Modifier 92 | .size(100.dp) 93 | .clip(CircleShape) 94 | ) { 95 | Image( 96 | modifier = Modifier.fillMaxSize(), 97 | painter = painterResource(R.drawable.logo), 98 | contentDescription = null 99 | ) 100 | } 101 | 102 | // Spacer 103 | Spacer( 104 | modifier = Modifier 105 | .fillMaxWidth() 106 | .height(25.dp) 107 | ) 108 | 109 | // Username 110 | OutlinedTextField( 111 | modifier = Modifier.fillMaxWidth(), 112 | value = loginViewModel.userName, 113 | onValueChange = { loginViewModel.userName = it }, 114 | label = { 115 | Text( 116 | text = "用户名" 117 | ) 118 | }, 119 | singleLine = true 120 | ) 121 | 122 | // Password 123 | OutlinedTextField( 124 | modifier = Modifier.fillMaxWidth(), 125 | value = loginViewModel.password, 126 | onValueChange = { loginViewModel.password = it }, 127 | label = { 128 | Text( 129 | text = "密码" 130 | ) 131 | }, 132 | keyboardOptions = KeyboardOptions( 133 | keyboardType = KeyboardType.Password 134 | ), 135 | visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), 136 | singleLine = true, 137 | trailingIcon = { 138 | Crossfade(targetState = showPassword) { 139 | IconButton(onClick = { 140 | showPassword = !showPassword 141 | }) { 142 | Icon( 143 | if (it) Icons.Default.Visibility else Icons.Default.VisibilityOff, 144 | null 145 | ) 146 | } 147 | } 148 | } 149 | ) 150 | 151 | // Spacer 152 | Spacer( 153 | modifier = Modifier 154 | .fillMaxWidth() 155 | .height(45.dp) 156 | ) 157 | 158 | // Login 159 | Button( 160 | modifier = Modifier.fillMaxWidth(), 161 | onClick = { 162 | if (loginViewModel.userName.isBlank() || loginViewModel.password.isBlank()) { 163 | Toast.makeText(context, "用户名或密码不能为空!", Toast.LENGTH_SHORT).show() 164 | return@Button 165 | } 166 | 167 | progressDialog.show() 168 | loginViewModel.login { 169 | // 处理结果 170 | if (it) { 171 | // 登录成功 172 | Toast.makeText(context, "登录成功", Toast.LENGTH_SHORT).show() 173 | navController.navigate("index"){ 174 | popUpTo("login"){ 175 | inclusive = true 176 | } 177 | } 178 | } else { 179 | // 登录失败 180 | failedDialog.show() 181 | } 182 | progressDialog.hide() 183 | } 184 | } 185 | ) { 186 | Text(text = "登录") 187 | } 188 | 189 | // Spacer 190 | Spacer( 191 | modifier = Modifier 192 | .fillMaxWidth() 193 | .height(10.dp) 194 | ) 195 | 196 | Row { 197 | // Register 198 | OutlinedButton( 199 | modifier = Modifier.fillMaxWidth(), 200 | onClick = { 201 | val intent = Intent( 202 | Intent.ACTION_VIEW, 203 | Uri.parse("https://ecchi.iwara.tv/user/register") 204 | ) 205 | context.startActivity(intent) 206 | } 207 | ) { 208 | Text(text = "注册账号") 209 | } 210 | } 211 | } 212 | } 213 | 214 | @Composable 215 | private fun TopBar(navController: NavController) { 216 | FullScreenTopBar( 217 | title = { 218 | Text(text = "登录账号") 219 | } 220 | ) 221 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.login 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.core.content.edit 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.viewModelScope 9 | import com.rerere.iwara4a.event.LoginEvent 10 | import com.rerere.iwara4a.model.session.SessionManager 11 | import com.rerere.iwara4a.repo.UserRepo 12 | import com.rerere.iwara4a.sharedPreferencesOf 13 | import com.rerere.iwara4a.util.postEvent 14 | import dagger.hilt.android.lifecycle.HiltViewModel 15 | import kotlinx.coroutines.launch 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class LoginViewModel @Inject constructor( 20 | private val userRepo: UserRepo, 21 | private val sessionManager: SessionManager 22 | ): ViewModel() { 23 | var userName by mutableStateOf("") 24 | var password by mutableStateOf("") 25 | var isLoginState by mutableStateOf(false) 26 | var errorContent by mutableStateOf("") 27 | 28 | init { 29 | val sharedPreferences = sharedPreferencesOf("session") 30 | userName = sharedPreferences.getString("username","")!! 31 | password = sharedPreferences.getString("password","")!! 32 | } 33 | 34 | fun login(result: (success: Boolean) -> Unit) { 35 | viewModelScope.launch { 36 | isLoginState = true 37 | // save 38 | val sharedPreferences = sharedPreferencesOf("session") 39 | sharedPreferences.edit { 40 | putString("username", userName) 41 | putString("password", password) 42 | } 43 | 44 | val response = userRepo.login(userName, password) 45 | 46 | // call event 47 | if(response.isSuccess()){ 48 | val session = response.read() 49 | sessionManager.update(session.key, session.value) 50 | postEvent(LoginEvent(session)) 51 | }else { 52 | errorContent = response.errorMessage() 53 | } 54 | // call back 55 | result(response.isSuccess()) 56 | 57 | isLoginState = false 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/search/SearchScreen.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.search 2 | 3 | import android.widget.Toast 4 | import androidx.compose.animation.Crossfade 5 | import androidx.compose.foundation.BorderStroke 6 | import androidx.compose.foundation.border 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.lazy.LazyColumn 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.foundation.text.KeyboardActions 12 | import androidx.compose.foundation.text.KeyboardOptions 13 | import androidx.compose.material.* 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.ArrowBack 16 | import androidx.compose.material.icons.filled.Close 17 | import androidx.compose.material.icons.filled.Search 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.platform.LocalContext 23 | import androidx.compose.ui.platform.LocalFocusManager 24 | import androidx.compose.ui.text.font.FontWeight 25 | import androidx.compose.ui.text.input.ImeAction 26 | import androidx.compose.ui.unit.dp 27 | import androidx.hilt.navigation.compose.hiltViewModel 28 | import androidx.navigation.NavController 29 | import androidx.paging.LoadState 30 | import androidx.paging.compose.LazyPagingItems 31 | import androidx.paging.compose.collectAsLazyPagingItems 32 | import androidx.paging.compose.items 33 | import com.google.accompanist.insets.navigationBarsPadding 34 | import com.google.accompanist.swiperefresh.SwipeRefresh 35 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 36 | import com.rerere.iwara4a.model.index.MediaPreview 37 | import com.rerere.iwara4a.ui.public.FullScreenTopBar 38 | import com.rerere.iwara4a.ui.public.MediaPreviewCard 39 | import com.rerere.iwara4a.ui.public.QueryParamSelector 40 | import com.rerere.iwara4a.util.noRippleClickable 41 | 42 | @Composable 43 | fun SearchScreen(navController: NavController, searchViewModel: SearchViewModel = hiltViewModel()) { 44 | Scaffold( 45 | topBar = { 46 | FullScreenTopBar( 47 | title = { 48 | Text(text = "搜索") 49 | }, 50 | navigationIcon = { 51 | IconButton(onClick = { navController.popBackStack() }) { 52 | Icon(Icons.Default.ArrowBack, null) 53 | } 54 | } 55 | ) 56 | } 57 | ) { 58 | val result = searchViewModel.pager.collectAsLazyPagingItems() 59 | 60 | Column( 61 | Modifier 62 | .fillMaxSize() 63 | .navigationBarsPadding() 64 | ) { 65 | SearchBar(searchViewModel, result) 66 | Result(navController, searchViewModel, result) 67 | } 68 | } 69 | } 70 | 71 | @Composable 72 | private fun Result( 73 | navController: NavController, 74 | searchViewModel: SearchViewModel, 75 | list: LazyPagingItems 76 | ) { 77 | if (list.loadState.refresh !is LoadState.Error) { 78 | Crossfade(searchViewModel.query) { 79 | if (it.isNotBlank()) { 80 | SwipeRefresh( 81 | state = rememberSwipeRefreshState(list.loadState.refresh == LoadState.Loading), 82 | onRefresh = { list.refresh() } 83 | ) { 84 | LazyColumn(Modifier.fillMaxSize()) { 85 | item { 86 | QueryParamSelector( 87 | queryParam = searchViewModel.searchParam, 88 | onChangeSort = { 89 | searchViewModel.searchParam.sortType = it 90 | list.refresh() 91 | }, 92 | onChangeFilters = { 93 | searchViewModel.searchParam.filters = it 94 | list.refresh() 95 | } 96 | ) 97 | } 98 | 99 | items(list) { 100 | MediaPreviewCard(navController, it!!) 101 | } 102 | 103 | when (list.loadState.append) { 104 | LoadState.Loading -> { 105 | item { 106 | Column( 107 | modifier = Modifier 108 | .fillMaxWidth() 109 | .padding(16.dp), 110 | horizontalAlignment = Alignment.CenterHorizontally 111 | ) { 112 | CircularProgressIndicator() 113 | Text(text = "加载中", fontWeight = FontWeight.Bold) 114 | } 115 | } 116 | } 117 | is LoadState.Error -> { 118 | item { 119 | Column( 120 | modifier = Modifier 121 | .fillMaxWidth() 122 | .noRippleClickable { list.retry() } 123 | .padding(16.dp), 124 | horizontalAlignment = Alignment.CenterHorizontally 125 | ) { 126 | Text(text = "加载失败,点击重试", fontWeight = FontWeight.Bold) 127 | } 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } else { 134 | // 也许可以加个搜索推荐? 135 | } 136 | } 137 | } else { 138 | Box(modifier = Modifier 139 | .fillMaxSize() 140 | .noRippleClickable { list.refresh() }, contentAlignment = Alignment.Center 141 | ) { 142 | Text(text = "加载错误,点击重新尝试搜索", fontWeight = FontWeight.Bold) 143 | } 144 | } 145 | } 146 | 147 | @Composable 148 | private fun SearchRecommend(text: String, onClick: (text: String) -> Unit) { 149 | Box(modifier = Modifier 150 | .padding(horizontal = 8.dp) 151 | .border(BorderStroke(1.dp, Color.Black), RoundedCornerShape(4.dp)) 152 | .clickable { onClick(text) } 153 | .padding(4.dp)) { 154 | Text(text = text) 155 | } 156 | } 157 | 158 | @Composable 159 | private fun SearchBar(searchViewModel: SearchViewModel, list: LazyPagingItems) { 160 | val context = LocalContext.current 161 | val focusManager = LocalFocusManager.current 162 | Card(modifier = Modifier.padding(8.dp), elevation = 4.dp, shape = RoundedCornerShape(6.dp)) { 163 | Row( 164 | modifier = 165 | Modifier 166 | .fillMaxWidth() 167 | .padding(horizontal = 4.dp), 168 | verticalAlignment = Alignment.CenterVertically 169 | ) { 170 | Box( 171 | modifier = Modifier 172 | .weight(1f), 173 | contentAlignment = Alignment.Center 174 | ) { 175 | TextField( 176 | modifier = Modifier.fillMaxWidth(), 177 | value = searchViewModel.query, 178 | onValueChange = { searchViewModel.query = it.replace("\n", "") }, 179 | maxLines = 1, 180 | placeholder = { 181 | Text(text = "搜索视频和图片") 182 | }, 183 | colors = TextFieldDefaults.textFieldColors( 184 | backgroundColor = Color.Transparent, 185 | focusedIndicatorColor = Color.Transparent, 186 | unfocusedIndicatorColor = Color.Transparent 187 | ), 188 | trailingIcon = { 189 | if (searchViewModel.query.isNotEmpty()) { 190 | IconButton(onClick = { searchViewModel.query = "" }) { 191 | Icon(Icons.Default.Close, null) 192 | } 193 | } 194 | }, 195 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), 196 | keyboardActions = KeyboardActions( 197 | onSearch = { 198 | if (searchViewModel.query.isBlank()) { 199 | Toast.makeText(context, "不能搜索空内容哦!", Toast.LENGTH_SHORT).show() 200 | } else { 201 | focusManager.clearFocus() 202 | list.refresh() 203 | } 204 | } 205 | ) 206 | ) 207 | } 208 | IconButton(onClick = { 209 | if (searchViewModel.query.isBlank()) { 210 | Toast.makeText(context, "不能搜索空内容哦!", Toast.LENGTH_SHORT).show() 211 | } else { 212 | focusManager.clearFocus() 213 | list.refresh() 214 | } 215 | }) { 216 | Icon(Icons.Default.Search, null) 217 | } 218 | } 219 | } 220 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.search 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.paging.Pager 9 | import androidx.paging.PagingConfig 10 | import androidx.paging.cachedIn 11 | import com.rerere.iwara4a.api.paging.SearchSource 12 | import com.rerere.iwara4a.model.index.MediaQueryParam 13 | import com.rerere.iwara4a.model.index.SortType 14 | import com.rerere.iwara4a.model.session.SessionManager 15 | import com.rerere.iwara4a.repo.MediaRepo 16 | import dagger.hilt.android.lifecycle.HiltViewModel 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class SearchViewModel @Inject constructor( 21 | private val sessionManager: SessionManager, 22 | private val mediaRepo: MediaRepo 23 | ) :ViewModel() { 24 | var query by mutableStateOf("") 25 | var searchParam by mutableStateOf(MediaQueryParam(SortType.DATE, emptyList())) 26 | 27 | val pager by lazy { 28 | Pager( 29 | PagingConfig( 30 | pageSize = 20, 31 | initialLoadSize = 20, 32 | prefetchDistance = 5 33 | ) 34 | ){ 35 | SearchSource( 36 | mediaRepo, 37 | sessionManager, 38 | query, 39 | searchParam 40 | ) 41 | }.flow.cachedIn(viewModelScope) 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/splash/SplashScreen.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.splash 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.primarySurface 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.draw.clip 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.res.painterResource 20 | import androidx.compose.ui.text.font.FontWeight 21 | import androidx.compose.ui.unit.dp 22 | import androidx.compose.ui.unit.sp 23 | import androidx.hilt.navigation.compose.hiltViewModel 24 | import androidx.navigation.NavController 25 | import com.rerere.iwara4a.R 26 | import kotlinx.coroutines.delay 27 | 28 | @Composable 29 | fun SplashScreen(navController: NavController, splashViewModel: SplashViewModel = hiltViewModel()){ 30 | Box(modifier = Modifier 31 | .fillMaxSize() 32 | .background(MaterialTheme.colors.primarySurface), contentAlignment = Alignment.Center){ 33 | 34 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 35 | Box(modifier = Modifier 36 | .size(80.dp) 37 | .clip(CircleShape)){ 38 | Image(modifier = Modifier.fillMaxSize(), painter = painterResource(R.drawable.logo), contentDescription = null) 39 | } 40 | Text(text = "IWARA", fontSize = 40.sp, fontWeight = FontWeight.Bold, color = Color.White) 41 | Text(text = "ecchi.iwara.tv", fontSize = 20.sp, color = Color.White) 42 | } 43 | } 44 | LaunchedEffect(Unit){ 45 | delay(1000L) 46 | 47 | // 前往主页 48 | if(splashViewModel.isLogin()) { 49 | navController.navigate("index") { 50 | popUpTo("splash") { 51 | inclusive = true 52 | } 53 | } 54 | } else { 55 | // 登录 56 | navController.navigate("login") { 57 | popUpTo("splash") { 58 | inclusive = true 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.splash 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.rerere.iwara4a.model.session.SessionManager 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import javax.inject.Inject 7 | 8 | @HiltViewModel 9 | class SplashViewModel @Inject constructor( 10 | private val sessionManager: SessionManager 11 | ): ViewModel() { 12 | fun isLogin() = sessionManager.session.key.isNotEmpty() 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/user/UserScreen.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.user 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.shape.CircleShape 7 | import androidx.compose.material.* 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.filled.ArrowBack 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.CompositionLocalProvider 12 | import androidx.compose.runtime.LaunchedEffect 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.draw.clip 16 | import androidx.compose.ui.res.painterResource 17 | import androidx.compose.ui.text.font.FontWeight 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.unit.sp 20 | import androidx.hilt.navigation.compose.hiltViewModel 21 | import androidx.navigation.NavController 22 | import com.google.accompanist.coil.rememberCoilPainter 23 | import com.google.accompanist.pager.ExperimentalPagerApi 24 | import com.google.accompanist.pager.HorizontalPager 25 | import com.google.accompanist.pager.rememberPagerState 26 | import com.rerere.iwara4a.R 27 | import com.rerere.iwara4a.model.user.UserData 28 | import com.rerere.iwara4a.ui.public.FullScreenTopBar 29 | import com.rerere.iwara4a.ui.public.TabItem 30 | import com.rerere.iwara4a.ui.theme.PINK 31 | import com.rerere.iwara4a.util.noRippleClickable 32 | 33 | @ExperimentalPagerApi 34 | @ExperimentalAnimationApi 35 | @Composable 36 | fun UserScreen( 37 | navController: NavController, 38 | userId: String, 39 | userViewModel: UserViewModel = hiltViewModel() 40 | ) { 41 | LaunchedEffect(Unit) { 42 | userViewModel.load(userId) 43 | } 44 | 45 | Scaffold( 46 | topBar = { 47 | TopBar(navController, userViewModel) 48 | } 49 | ) { 50 | if (userViewModel.isLoaded()) { 51 | UserInfo(navController, userViewModel.userData) 52 | } else if(userViewModel.loading) { 53 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 54 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 55 | CircularProgressIndicator() 56 | Text(text = "加载中", fontWeight = FontWeight.Bold) 57 | } 58 | } 59 | } else if(userViewModel.error){ 60 | Box(modifier = Modifier.fillMaxSize().noRippleClickable { userViewModel.load(userId) }, contentAlignment = Alignment.Center) { 61 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 62 | Box( 63 | modifier = Modifier 64 | .size(160.dp) 65 | .padding(10.dp) 66 | .clip(CircleShape) 67 | ) { 68 | Image( 69 | modifier = Modifier.fillMaxSize(), 70 | painter = painterResource(R.drawable.anime_4), 71 | contentDescription = null 72 | ) 73 | } 74 | Text(text = "加载失败,点击重试", fontWeight = FontWeight.Bold) 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | @ExperimentalAnimationApi 82 | @ExperimentalPagerApi 83 | @Composable 84 | private fun UserInfo(navController: NavController, userData: UserData) { 85 | Column { 86 | // 用户信息 87 | Card( 88 | modifier = Modifier 89 | .fillMaxWidth() 90 | .padding(16.dp) 91 | ) { 92 | Column(Modifier.padding(8.dp)) { 93 | Row(verticalAlignment = Alignment.CenterVertically) { 94 | Box( 95 | modifier = Modifier 96 | .size(60.dp) 97 | .clip(CircleShape) 98 | ) { 99 | Image( 100 | modifier = Modifier.fillMaxSize(), 101 | painter = rememberCoilPainter(userData.pic), 102 | contentDescription = null 103 | ) 104 | } 105 | 106 | Column(Modifier.padding(horizontal = 16.dp)) { 107 | Text( 108 | text = userData.username, 109 | fontWeight = FontWeight.Bold, 110 | fontSize = 18.sp, 111 | color = PINK 112 | ) 113 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { 114 | Text( 115 | text = "注册日期: ${userData.joinDate}" 116 | ) 117 | Text( 118 | text = "最后在线: ${userData.lastSeen}" 119 | ) 120 | } 121 | } 122 | } 123 | 124 | Spacer(modifier = Modifier.height(8.dp)) 125 | 126 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { 127 | Text(text = userData.about, maxLines = 5) 128 | } 129 | } 130 | } 131 | // 评论/ 视频 / 图片 132 | val pagerState = rememberPagerState(pageCount = 3) 133 | com.rerere.iwara4a.ui.public.TabRow { 134 | TabItem(pagerState = pagerState, index = 0, text = "评论") 135 | TabItem(pagerState = pagerState, index = 1, text = "发布的视频") 136 | TabItem(pagerState = pagerState, index = 2, text = "发布的图片") 137 | } 138 | HorizontalPager(modifier = Modifier.fillMaxWidth().weight(1f), state = pagerState) { 139 | when(it){ 140 | 0 -> { 141 | Box(modifier = Modifier.fillMaxSize()){ 142 | 143 | } 144 | } 145 | 1 -> { 146 | Box(modifier = Modifier.fillMaxSize()){ 147 | 148 | } 149 | } 150 | 2 -> { 151 | Box(modifier = Modifier.fillMaxSize()){ 152 | 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | 160 | @Composable 161 | private fun TopBar(navController: NavController, userViewModel: UserViewModel) { 162 | FullScreenTopBar( 163 | title = { 164 | Text(text = if(userViewModel.isLoaded()) userViewModel.userData.username else "用户信息") 165 | }, 166 | navigationIcon = { 167 | IconButton(onClick = { 168 | navController.popBackStack() 169 | }) { 170 | Icon(Icons.Default.ArrowBack, null) 171 | } 172 | } 173 | ) 174 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/user/UserViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.user 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import com.rerere.iwara4a.model.session.SessionManager 9 | import com.rerere.iwara4a.model.user.UserData 10 | import com.rerere.iwara4a.repo.UserRepo 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.launch 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class UserViewModel @Inject constructor( 17 | private val sessionManager: SessionManager, 18 | private val userRepo: UserRepo 19 | ): ViewModel(){ 20 | var loading by mutableStateOf(false) 21 | var error by mutableStateOf(false) 22 | var userData by mutableStateOf(UserData.LOADING) 23 | 24 | fun load(userId: String){ 25 | viewModelScope.launch { 26 | loading = true 27 | error = false 28 | 29 | val response = userRepo.getUser(sessionManager.session, userId) 30 | if(response.isSuccess()){ 31 | userData = response.read() 32 | } else { 33 | error = true 34 | } 35 | 36 | loading = false 37 | } 38 | } 39 | 40 | fun isLoaded() = userData != UserData.LOADING 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/video/VideoScreen.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.video 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Activity 5 | import android.content.res.Configuration 6 | import android.os.Build 7 | import android.view.WindowManager 8 | import android.widget.Toast 9 | import androidx.activity.compose.BackHandler 10 | import androidx.annotation.RequiresApi 11 | import androidx.compose.animation.Crossfade 12 | import androidx.compose.animation.ExperimentalAnimationApi 13 | import androidx.compose.animation.animateContentSize 14 | import androidx.compose.foundation.Image 15 | import androidx.compose.foundation.background 16 | import androidx.compose.foundation.clickable 17 | import androidx.compose.foundation.layout.* 18 | import androidx.compose.foundation.lazy.LazyColumn 19 | import androidx.compose.foundation.lazy.items 20 | import androidx.compose.foundation.shape.CircleShape 21 | import androidx.compose.foundation.shape.RoundedCornerShape 22 | import androidx.compose.material.* 23 | import androidx.compose.material.icons.Icons 24 | import androidx.compose.material.icons.filled.* 25 | import androidx.compose.runtime.* 26 | import androidx.compose.ui.Alignment 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.draw.clip 29 | import androidx.compose.ui.graphics.Color 30 | import androidx.compose.ui.layout.ContentScale 31 | import androidx.compose.ui.platform.LocalContext 32 | import androidx.compose.ui.res.painterResource 33 | import androidx.compose.ui.text.font.FontWeight 34 | import androidx.compose.ui.unit.dp 35 | import androidx.compose.ui.unit.sp 36 | import androidx.hilt.navigation.compose.hiltViewModel 37 | import androidx.navigation.NavController 38 | import androidx.paging.LoadState 39 | import androidx.paging.compose.collectAsLazyPagingItems 40 | import androidx.paging.compose.items 41 | import com.google.accompanist.coil.rememberCoilPainter 42 | import com.google.accompanist.insets.navigationBarsWithImePadding 43 | import com.google.accompanist.pager.ExperimentalPagerApi 44 | import com.google.accompanist.pager.HorizontalPager 45 | import com.google.accompanist.pager.rememberPagerState 46 | import com.google.accompanist.swiperefresh.SwipeRefresh 47 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 48 | import com.rerere.iwara4a.R 49 | import com.rerere.iwara4a.model.detail.video.VideoDetail 50 | import com.rerere.iwara4a.model.index.MediaType 51 | import com.rerere.iwara4a.ui.local.LocalScreenOrientation 52 | import com.rerere.iwara4a.ui.public.CommentItem 53 | import com.rerere.iwara4a.ui.public.ExoPlayer 54 | import com.rerere.iwara4a.ui.public.FullScreenTopBar 55 | import com.rerere.iwara4a.ui.public.TabItem 56 | import com.rerere.iwara4a.ui.theme.PINK 57 | import com.rerere.iwara4a.util.noRippleClickable 58 | import com.rerere.iwara4a.util.shareMedia 59 | 60 | @ExperimentalMaterialApi 61 | @ExperimentalAnimationApi 62 | @ExperimentalPagerApi 63 | @SuppressLint("WrongConstant") 64 | @RequiresApi(Build.VERSION_CODES.R) 65 | @Composable 66 | fun VideoScreen( 67 | navController: NavController, 68 | videoId: String, 69 | videoViewModel: VideoViewModel = hiltViewModel() 70 | ) { 71 | val orientation = LocalScreenOrientation.current 72 | val context = LocalContext.current as Activity 73 | 74 | // 判断视频是否加载了 75 | fun isVideoLoaded() = 76 | videoViewModel.videoDetail != VideoDetail.LOADING && !videoViewModel.error && !videoViewModel.isLoading 77 | 78 | fun getTitle() = 79 | if (videoViewModel.isLoading) "加载中" else if (isVideoLoaded()) videoViewModel.videoDetail.title else if (videoViewModel.error) "加载失败" else "视频页面" 80 | 81 | val videoLink = if (isVideoLoaded()) videoViewModel.videoDetail.videoLinks[0].toLink() else "" 82 | 83 | // 加载视频 84 | LaunchedEffect(Unit) { 85 | videoViewModel.loadVideo(videoId) 86 | } 87 | 88 | // 响应旋转 89 | LaunchedEffect(orientation) { 90 | if (isVideoLoaded()) { 91 | if (orientation == Configuration.ORIENTATION_LANDSCAPE) { 92 | context.window.setFlags( 93 | WindowManager.LayoutParams.FLAG_FULLSCREEN, 94 | WindowManager.LayoutParams.FLAG_FULLSCREEN 95 | ) 96 | } else { 97 | context.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) 98 | } 99 | } 100 | } 101 | 102 | // 处理返回 103 | BackHandler(isVideoLoaded() && orientation == Configuration.ORIENTATION_LANDSCAPE) { 104 | context.requestedOrientation = Configuration.ORIENTATION_PORTRAIT 105 | } 106 | 107 | 108 | Scaffold( 109 | topBar = { 110 | if (orientation == Configuration.ORIENTATION_PORTRAIT) { 111 | FullScreenTopBar( 112 | navigationIcon = { 113 | IconButton(onClick = { navController.popBackStack() }) { 114 | Icon(Icons.Default.ArrowBack, null) 115 | } 116 | }, 117 | title = { 118 | Text(text = getTitle(), maxLines = 1) 119 | } 120 | ) 121 | } 122 | } 123 | ) { 124 | Column( 125 | modifier = Modifier 126 | .fillMaxSize() 127 | .navigationBarsWithImePadding() 128 | ) { 129 | ExoPlayer( 130 | modifier = if (orientation == Configuration.ORIENTATION_PORTRAIT) 131 | Modifier 132 | .fillMaxWidth() 133 | .wrapContentHeight() 134 | .requiredHeightIn(max = 210.dp) 135 | .background(Color.Black) 136 | else 137 | Modifier 138 | .fillMaxSize() 139 | .background(Color.Black), 140 | videoLink = videoLink 141 | ) 142 | 143 | when { 144 | isVideoLoaded() -> { 145 | Box( 146 | modifier = Modifier 147 | .weight(1f) 148 | .fillMaxWidth() 149 | ) { 150 | VideoInfo(navController, videoViewModel, videoViewModel.videoDetail) 151 | } 152 | } 153 | videoViewModel.isLoading -> { 154 | Box( 155 | modifier = Modifier 156 | .fillMaxSize(), 157 | contentAlignment = Alignment.Center 158 | ) { 159 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 160 | CircularProgressIndicator() 161 | Text(text = "加载中") 162 | } 163 | } 164 | } 165 | videoViewModel.error -> { 166 | Box( 167 | modifier = Modifier 168 | .fillMaxSize() 169 | .noRippleClickable { videoViewModel.loadVideo(videoId) }, 170 | contentAlignment = Alignment.Center 171 | ) { 172 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 173 | Box( 174 | modifier = Modifier 175 | .size(160.dp) 176 | .padding(10.dp) 177 | .clip(CircleShape) 178 | ) { 179 | Image( 180 | modifier = Modifier.fillMaxSize(), 181 | painter = painterResource(R.drawable.anime_4), 182 | contentDescription = null 183 | ) 184 | } 185 | Text(text = "加载失败,点击重试~ (土豆服务器日常)", fontWeight = FontWeight.Bold) 186 | } 187 | } 188 | } 189 | } 190 | } 191 | } 192 | } 193 | 194 | @ExperimentalMaterialApi 195 | @ExperimentalAnimationApi 196 | @ExperimentalPagerApi 197 | @Composable 198 | private fun VideoInfo( 199 | navController: NavController, 200 | videoViewModel: VideoViewModel, 201 | videoDetail: VideoDetail 202 | ) { 203 | val pagerState = rememberPagerState(pageCount = 2, initialPage = 0) 204 | val coroutineScope = rememberCoroutineScope() 205 | Column(Modifier.fillMaxSize()) { 206 | Row( 207 | modifier = Modifier 208 | .fillMaxWidth() 209 | .height(45.dp), 210 | verticalAlignment = Alignment.CenterVertically 211 | ) { 212 | TabItem(pagerState, 0, "简介") 213 | TabItem(pagerState, 1, "评论") 214 | } 215 | 216 | Box( 217 | modifier = Modifier 218 | .fillMaxWidth() 219 | .weight(1f) 220 | ) { 221 | HorizontalPager( 222 | modifier = Modifier 223 | .fillMaxWidth(), 224 | state = pagerState 225 | ) { 226 | when (it) { 227 | 0 -> VideoDescription(navController, videoViewModel, videoDetail) 228 | 1 -> CommentPage(navController, videoViewModel) 229 | } 230 | } 231 | } 232 | } 233 | } 234 | 235 | @ExperimentalMaterialApi 236 | @Composable 237 | private fun VideoDescription( 238 | navController: NavController, 239 | videoViewModel: VideoViewModel, 240 | videoDetail: VideoDetail 241 | ) { 242 | val context = LocalContext.current 243 | LazyColumn(modifier = Modifier.fillMaxSize()) { 244 | item { 245 | // 视频简介 246 | Card(modifier = Modifier.padding(8.dp), elevation = 4.dp) { 247 | Column( 248 | modifier = Modifier 249 | .animateContentSize() 250 | .padding(16.dp) 251 | ) { 252 | // 作者信息 253 | Row( 254 | modifier = Modifier 255 | .fillMaxWidth() 256 | .padding(vertical = 5.dp), 257 | verticalAlignment = Alignment.CenterVertically 258 | ) { 259 | // 作者头像 260 | Box( 261 | modifier = Modifier 262 | .size(40.dp) 263 | .clip(CircleShape) 264 | .noRippleClickable { 265 | navController.navigate("user/${videoDetail.authorId}") 266 | } 267 | ) { 268 | Image( 269 | modifier = Modifier.fillMaxSize(), 270 | painter = rememberCoilPainter(videoDetail.authorPic), 271 | contentDescription = null 272 | ) 273 | } 274 | 275 | // 作者名字 276 | Text( 277 | modifier = Modifier 278 | .padding(horizontal = 16.dp) 279 | .noRippleClickable { 280 | navController.navigate("user/${videoDetail.authorId}") 281 | }, 282 | text = videoDetail.authorName, 283 | fontWeight = FontWeight.Bold, 284 | fontSize = 25.sp, 285 | color = PINK 286 | ) 287 | 288 | // 关注 289 | Box( 290 | modifier = Modifier 291 | .clip(RoundedCornerShape(4.dp)) 292 | .clickable { 293 | videoViewModel.handleFollow { action, success -> 294 | if (action) { 295 | Toast 296 | .makeText( 297 | context, 298 | if (success) "关注了该UP主! ヾ(≧▽≦*)o" else "关注失败", 299 | Toast.LENGTH_SHORT 300 | ) 301 | .show() 302 | } else { 303 | Toast 304 | .makeText( 305 | context, 306 | if (success) "已取消关注" else "取消关注失败", 307 | Toast.LENGTH_SHORT 308 | ) 309 | .show() 310 | } 311 | } 312 | } 313 | .background( 314 | if (videoDetail.follow) Color.LightGray else Color( 315 | 0xfff45a8d 316 | ) 317 | ) 318 | .padding(4.dp), 319 | ) { 320 | Text( 321 | text = if (videoDetail.follow) "已关注" else "+ 关注", 322 | color = if (videoDetail.follow) Color.Black else Color.White 323 | ) 324 | } 325 | } 326 | // 视频信息 327 | Row(Modifier.padding(vertical = 4.dp)) { 328 | Text(text = "播放: ${videoDetail.watchs} 喜欢: ${videoDetail.likes}") 329 | } 330 | 331 | // 视频介绍 332 | var expand by remember { 333 | mutableStateOf(false) 334 | } 335 | Crossfade(expand) { 336 | Column( 337 | modifier = Modifier 338 | .fillMaxWidth() 339 | .padding(vertical = 4.dp) 340 | ) { 341 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { 342 | // TODO: 解析URL 343 | Text( 344 | text = videoDetail.description, 345 | maxLines = if (expand) 10 else 1 346 | ) 347 | } 348 | Row( 349 | Modifier 350 | .fillMaxWidth() 351 | .noRippleClickable { expand = !expand } 352 | .padding(horizontal = 8.dp), 353 | horizontalArrangement = Arrangement.End 354 | ) { 355 | IconButton( 356 | modifier = Modifier.size(20.dp), 357 | onClick = { expand = !expand }) { 358 | Icon( 359 | imageVector = if (it) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward, 360 | contentDescription = null 361 | ) 362 | } 363 | } 364 | } 365 | } 366 | 367 | // 操作按钮 368 | Row( 369 | modifier = Modifier 370 | .fillMaxWidth() 371 | .padding(4.dp) 372 | ) { 373 | Column( 374 | modifier = Modifier 375 | .weight(1f) 376 | .clickable { 377 | videoViewModel.handleLike { action, success -> 378 | if (action) { 379 | Toast 380 | .makeText( 381 | context, 382 | if (success) "点赞大成功! ヾ(≧▽≦*)o" else "点赞失败", 383 | Toast.LENGTH_SHORT 384 | ) 385 | .show() 386 | } else { 387 | Toast 388 | .makeText( 389 | context, 390 | if (success) "已取消点赞" else "取消点赞失败", 391 | Toast.LENGTH_SHORT 392 | ) 393 | .show() 394 | } 395 | } 396 | }, horizontalAlignment = Alignment.CenterHorizontally 397 | ) { 398 | Icon( 399 | imageVector = if (videoDetail.isLike) Icons.Default.Favorite else Icons.Default.FavoriteBorder, 400 | contentDescription = null, 401 | tint = if (videoDetail.isLike) Color(0xfff45a8d) else Color.LightGray 402 | ) 403 | Text(text = if (videoDetail.isLike) "已喜欢" else "喜欢") 404 | } 405 | 406 | Column( 407 | modifier = Modifier 408 | .weight(1f) 409 | .clickable { }, horizontalAlignment = Alignment.CenterHorizontally 410 | ) { 411 | Icon(Icons.Default.Subscriptions, null) 412 | Text(text = "收藏") 413 | } 414 | 415 | Column( 416 | modifier = Modifier 417 | .weight(1f) 418 | .clickable { shareMedia(context, MediaType.VIDEO, videoDetail.id) }, 419 | horizontalAlignment = Alignment.CenterHorizontally 420 | ) { 421 | Icon(Icons.Default.Share, null) 422 | Text(text = "分享") 423 | } 424 | 425 | Column( 426 | modifier = Modifier 427 | .weight(1f) 428 | .clickable { }, horizontalAlignment = Alignment.CenterHorizontally 429 | ) { 430 | Icon(Icons.Default.Download, null) 431 | Text(text = "下载") 432 | } 433 | } 434 | } 435 | } 436 | } 437 | // 更多视频 438 | item { 439 | Text( 440 | text = "该作者的其他视频:", 441 | fontWeight = FontWeight.Bold, 442 | modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) 443 | ) 444 | } 445 | 446 | items(videoDetail.moreVideo) { 447 | Card( 448 | modifier = Modifier 449 | .fillMaxWidth() 450 | .padding(16.dp) 451 | ) { 452 | Row( 453 | modifier = Modifier 454 | .fillMaxWidth() 455 | .clickable { 456 | println(it.id) 457 | navController.navigate("video/${it.id}") 458 | } 459 | .padding(8.dp), 460 | verticalAlignment = Alignment.CenterVertically 461 | ) { 462 | Box( 463 | modifier = Modifier 464 | .height(60.dp) 465 | .clip(RoundedCornerShape(5.dp)) 466 | ) { 467 | Image( 468 | modifier = Modifier.fillMaxHeight(), 469 | painter = rememberCoilPainter(it.pic), 470 | contentDescription = null, 471 | contentScale = ContentScale.FillHeight 472 | ) 473 | } 474 | 475 | Column( 476 | modifier = Modifier 477 | .weight(1f) 478 | .padding(horizontal = 16.dp) 479 | ) { 480 | Text(text = it.title, fontWeight = FontWeight.Bold) 481 | CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { 482 | Text(text = "播放: ${it.watchs} 喜欢: ${it.likes}") 483 | } 484 | } 485 | } 486 | } 487 | } 488 | } 489 | } 490 | 491 | @Composable 492 | private fun CommentPage(navController: NavController, videoViewModel: VideoViewModel) { 493 | val pager = videoViewModel.commentPager.collectAsLazyPagingItems() 494 | val state = rememberSwipeRefreshState(pager.loadState.refresh == LoadState.Loading) 495 | if (pager.loadState.refresh is LoadState.Error) { 496 | Box( 497 | modifier = Modifier 498 | .fillMaxSize() 499 | .noRippleClickable { pager.retry() }, contentAlignment = Alignment.Center 500 | ) { 501 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 502 | Box( 503 | modifier = Modifier 504 | .size(160.dp) 505 | .padding(10.dp) 506 | .clip(CircleShape) 507 | ) { 508 | Image( 509 | modifier = Modifier.fillMaxSize(), 510 | painter = painterResource(R.drawable.anime_4), 511 | contentDescription = null 512 | ) 513 | } 514 | Text(text = "加载失败,点击重试~ (土豆服务器日常)", fontWeight = FontWeight.Bold) 515 | } 516 | } 517 | } else { 518 | Column(Modifier.fillMaxSize()) { 519 | SwipeRefresh( 520 | modifier = Modifier 521 | .fillMaxWidth() 522 | .weight(1f), 523 | state = state, 524 | onRefresh = { pager.refresh() }) { 525 | LazyColumn(modifier = Modifier.fillMaxSize()) { 526 | if (pager.itemCount == 0 && pager.loadState.refresh is LoadState.NotLoading) { 527 | item { 528 | Box( 529 | modifier = Modifier 530 | .fillMaxWidth() 531 | .height(150.dp), contentAlignment = Alignment.Center 532 | ) { 533 | Text(text = "暂无评论", fontWeight = FontWeight.Bold) 534 | } 535 | } 536 | } 537 | 538 | items(pager) { 539 | CommentItem(navController, it!!) 540 | } 541 | 542 | when (pager.loadState.append) { 543 | LoadState.Loading -> { 544 | item { 545 | Column( 546 | modifier = Modifier 547 | .fillMaxWidth() 548 | .padding(16.dp), 549 | horizontalAlignment = Alignment.CenterHorizontally 550 | ) { 551 | CircularProgressIndicator() 552 | Text(text = "加载中", fontWeight = FontWeight.Bold) 553 | } 554 | } 555 | } 556 | is LoadState.Error -> { 557 | item { 558 | Column( 559 | modifier = Modifier 560 | .fillMaxWidth() 561 | .noRippleClickable { pager.retry() } 562 | .padding(16.dp), 563 | horizontalAlignment = Alignment.CenterHorizontally 564 | ) { 565 | Text(text = "加载失败,点击重试", fontWeight = FontWeight.Bold) 566 | } 567 | } 568 | } 569 | } 570 | } 571 | } 572 | ReplyBox() 573 | } 574 | } 575 | } 576 | 577 | @Composable 578 | fun ReplyBox() { 579 | var content by remember { 580 | mutableStateOf("") 581 | } 582 | Box( 583 | modifier = Modifier 584 | .fillMaxWidth() 585 | ) { 586 | Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { 587 | TextField( 588 | modifier = Modifier.weight(1f), 589 | value = content, 590 | onValueChange = { content = it }, 591 | placeholder = { 592 | Text(text = "回复请注意礼仪哦~") 593 | }, 594 | maxLines = 3, 595 | label = { 596 | Text(text = "评论视频") 597 | } 598 | ) 599 | IconButton(onClick = { /*TODO*/ }) { 600 | Icon(Icons.Default.EmojiEmotions, null) 601 | } 602 | } 603 | } 604 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/screen/video/VideoViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.screen.video 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.paging.Pager 9 | import androidx.paging.PagingConfig 10 | import androidx.paging.cachedIn 11 | import com.rerere.iwara4a.api.paging.CommentSource 12 | import com.rerere.iwara4a.model.detail.video.VideoDetail 13 | import com.rerere.iwara4a.model.index.MediaType 14 | import com.rerere.iwara4a.model.session.SessionManager 15 | import com.rerere.iwara4a.repo.MediaRepo 16 | import dagger.hilt.android.lifecycle.HiltViewModel 17 | import kotlinx.coroutines.launch 18 | import javax.inject.Inject 19 | 20 | @HiltViewModel 21 | class VideoViewModel @Inject constructor( 22 | private val sessionManager: SessionManager, 23 | private val mediaRepo: MediaRepo 24 | ): ViewModel() { 25 | var videoId by mutableStateOf("") 26 | var isLoading by mutableStateOf(false) 27 | var error by mutableStateOf(false) 28 | var videoDetail by mutableStateOf(VideoDetail.LOADING) 29 | 30 | val commentPager by lazy { 31 | Pager( 32 | config = PagingConfig( 33 | pageSize = 30, 34 | initialLoadSize = 30 35 | ) 36 | ) { 37 | CommentSource( 38 | sessionManager = sessionManager, 39 | mediaRepo = mediaRepo, 40 | mediaType = MediaType.VIDEO, 41 | mediaId = videoDetail.id 42 | ) 43 | }.flow.cachedIn(viewModelScope) 44 | } 45 | 46 | fun loadVideo(id: String){ 47 | if(videoDetail != VideoDetail.LOADING){ 48 | return 49 | } 50 | 51 | viewModelScope.launch { 52 | videoId = id 53 | isLoading = true 54 | error = false 55 | 56 | val response = mediaRepo.getVideoDetail(sessionManager.session, id) 57 | if(response.isSuccess()){ 58 | videoDetail = response.read() 59 | }else { 60 | error = true 61 | } 62 | 63 | isLoading = false 64 | } 65 | } 66 | 67 | fun handleLike(result: (action: Boolean, success: Boolean) -> Unit){ 68 | val action = !videoDetail.isLike 69 | viewModelScope.launch { 70 | val response = mediaRepo.like(sessionManager.session, action, videoDetail.likeLink) 71 | if(response.isSuccess()){ 72 | videoDetail = videoDetail.copy(isLike = response.read().flagStatus == "flagged") 73 | } 74 | result(action, response.isSuccess()) 75 | } 76 | } 77 | 78 | fun handleFollow(result: (action: Boolean, success: Boolean) -> Unit){ 79 | val action = !videoDetail.follow 80 | viewModelScope.launch { 81 | val response = mediaRepo.follow(sessionManager.session, action, videoDetail.followLink) 82 | if(response.isSuccess()){ 83 | videoDetail = videoDetail.copy(follow = response.read().flagStatus == "flagged") 84 | } 85 | result(action, response.isSuccess()) 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val TEAL300 = Color(0xFF4DB6AC) 6 | val TEAL700 = Color(0xFF00796B) 7 | val PINK = Color(0xfff45a8d) 8 | val BACKGROUND = Color(0xFFF2F3F5) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.Color 9 | 10 | private val DarkColorPalette = darkColors( 11 | primary = Color.Gray, 12 | primaryVariant = Color.DarkGray, 13 | secondary = Color.LightGray, 14 | secondaryVariant = Color.Black 15 | ) 16 | 17 | private val LightColorPalette = lightColors( 18 | primary = TEAL300, 19 | primaryVariant = TEAL700, 20 | secondary = Color(0xFF64FFDA), 21 | secondaryVariant = Color(0xFF1DE9B6), 22 | ) 23 | 24 | @Composable 25 | fun Iwara4aTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { 26 | val colors = if (darkTheme) { 27 | DarkColorPalette 28 | } else { 29 | LightColorPalette 30 | } 31 | 32 | MaterialTheme( 33 | colors = colors, 34 | typography = Typography, 35 | shapes = Shapes, 36 | content = content 37 | ) 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.ui.theme 2 | 3 | import androidx.compose.material.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 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/AutoRetry.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util 2 | 3 | import android.util.Log 4 | import androidx.annotation.IntRange 5 | import com.rerere.iwara4a.api.Response 6 | 7 | private const val TAG = "AutoRetry" 8 | 9 | /** 10 | * 自动重试函数, 用于iwara的服务器大概率会无响应,因此需要尝试多次才能获取到响应内容 11 | * 12 | * @param maxRetry 重试次数 13 | * @param action 重试体 14 | * @return 最终响应 15 | */ 16 | suspend fun autoRetry( 17 | @IntRange(from = 2) maxRetry: Int = 10, // 重连次数 18 | action: suspend () -> Response 19 | ): Response { 20 | repeat(maxRetry - 1) { 21 | Log.i(TAG, "autoRetry: Try to get response: ${it + 1}/$maxRetry") 22 | val start = System.currentTimeMillis() 23 | val response = action() 24 | if (response.isSuccess()) { 25 | Log.i(TAG, "autoRetry: Successful get response (${System.currentTimeMillis() - start} ms)") 26 | return response 27 | } 28 | } 29 | Log.i(TAG, "autoRetry: Try to get response: $maxRetry*/$maxRetry") 30 | return action() 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/EventBusExt.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util 2 | 3 | import org.greenrobot.eventbus.EventBus 4 | 5 | /** 6 | * 提供快速全局访问eventBus的实例 7 | */ 8 | val eventBus: EventBus = EventBus.getDefault() 9 | 10 | /** 11 | * 快速触发一个事件 12 | */ 13 | fun postEvent(event: T) = eventBus.post(event) 14 | 15 | /** 16 | * 快速注册监听器 17 | */ 18 | fun T.registerListener() = eventBus.register(this) 19 | 20 | /** 21 | * 快速注销监听器 22 | */ 23 | fun T.unregisterListener() = eventBus.unregister(this) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/ModifierEx.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.interaction.MutableInteractionSource 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.composed 9 | 10 | @Composable 11 | inline fun Modifier.noRippleClickable(crossinline onClick: ()->Unit): Modifier = composed { 12 | clickable(indication = null, 13 | interactionSource = remember { MutableInteractionSource() }) { 14 | onClick() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/PageStateExt.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util 2 | 3 | import com.google.accompanist.pager.ExperimentalPagerApi 4 | import com.google.accompanist.pager.PagerState 5 | import kotlin.math.roundToInt 6 | 7 | @ExperimentalPagerApi 8 | val PagerState.currentVisualPage: Int 9 | get() { 10 | if(currentPageOffset != 0f){ 11 | return (currentPage + currentPageOffset.roundToInt()).coerceIn(0 until pageCount) 12 | } 13 | return this.currentPage 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/RecommendSearch.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util 2 | 3 | val RECOMMENDED_SEARCH = listOf( 4 | "MMD", 5 | "Miku", 6 | "Haku", 7 | "MMD R18" 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/ShareUtil.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import com.rerere.iwara4a.model.index.MediaType 6 | 7 | fun shareMedia(context: Context, mediaType: MediaType, mediaId: String){ 8 | val sendIntent: Intent = Intent().apply { 9 | action = Intent.ACTION_SEND 10 | putExtra(Intent.EXTRA_TEXT, "https://ecchi.iwara.tv/${mediaType.value}/$mediaId") 11 | type = "text/plain" 12 | } 13 | 14 | val shareIntent = Intent.createChooser(sendIntent, null) 15 | context.startActivity(shareIntent) 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/okhttp/CookieJarHelper.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util.okhttp 2 | 3 | import com.rerere.iwara4a.model.session.Session 4 | import okhttp3.* 5 | 6 | private val HAS_JS = Cookie.Builder() 7 | .name("has_js") 8 | .value("1") 9 | .domain("ecchi.iwara.tv") 10 | .build() 11 | 12 | fun Request.Builder.applyCookie(session: Session) = this.header("cookie", "${session.key}=${session.value}; has_js=1") 13 | 14 | class CookieJarHelper : CookieJar, Iterable { 15 | private var cookies = ArrayList() 16 | 17 | override fun loadForRequest(url: HttpUrl): List { 18 | return cookies 19 | } 20 | 21 | override fun saveFromResponse(url: HttpUrl, cookies: List) { 22 | this.cookies = ArrayList(cookies) 23 | } 24 | 25 | override fun iterator(): Iterator = cookies.iterator() 26 | 27 | fun clean() = cookies.clear() 28 | 29 | fun init(session: Session) { 30 | clean() 31 | if (session.isNotEmpty()) { 32 | cookies.add(session.toCookie()) 33 | cookies.add(HAS_JS) 34 | } else { 35 | println("### NOT LOGIN ###") 36 | } 37 | } 38 | } 39 | 40 | fun OkHttpClient.getCookie() = this.cookieJar as CookieJarHelper -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/okhttp/HeaderInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util.okhttp 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Request 5 | import okhttp3.Response 6 | 7 | class HeaderInterceptor(private val name: String, private val value: String) : Interceptor { 8 | override fun intercept(chain: Interceptor.Chain): Response { 9 | val headerRequest: Request = chain.request() 10 | .newBuilder() 11 | .addHeader(name , value) 12 | .build() 13 | return chain.proceed(headerRequest) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/okhttp/OkhttpUtil.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util.okhttp 2 | 3 | import kotlinx.coroutines.suspendCancellableCoroutine 4 | import okhttp3.Call 5 | import okhttp3.Callback 6 | import okhttp3.Response 7 | import java.io.IOException 8 | import kotlin.coroutines.resume 9 | import kotlin.coroutines.resumeWithException 10 | 11 | suspend fun Call.await(): Response { 12 | return suspendCancellableCoroutine { 13 | enqueue(object : Callback { 14 | override fun onFailure(call: Call, e: IOException) { 15 | if(it.isCancelled) return 16 | it.resumeWithException(e) 17 | } 18 | 19 | override fun onResponse(call: Call, response: Response) { 20 | it.resume(response) 21 | } 22 | }) 23 | 24 | it.invokeOnCancellation { 25 | try { 26 | cancel() 27 | } catch (e: Exception){ 28 | println("===== CANCEL ======") 29 | // IGNORE 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/okhttp/RetryInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util.okhttp 2 | 3 | import android.util.Log 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | 7 | /** 8 | * Custom, retry N interceptors 9 | * Set by :addInterceptor 10 | */ 11 | const val MAX_RETRY = 3 12 | 13 | class Retry : Interceptor { 14 | private var retryNum = 0; // If set to 3 retry, the maximum possible request 4 times (default 1 + 3 retry) 15 | 16 | override fun intercept(chain: Interceptor.Chain): Response { 17 | val request = chain.request() 18 | var response = chain.proceed(request); 19 | Log.i("Retry", "num:$retryNum"); 20 | while (!response.isSuccessful && retryNum < MAX_RETRY) { 21 | retryNum++; 22 | Log.i("Retry", "num:$retryNum"); 23 | response = chain.proceed(request); 24 | } 25 | return response; 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/rerere/iwara4a/util/okhttp/UserAgentInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.rerere.iwara4a.util.okhttp 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Request 5 | import okhttp3.Response 6 | 7 | class UserAgentInterceptor(private val userAgent: String) : Interceptor { 8 | override fun intercept(chain: Interceptor.Chain): Response { 9 | val userAgentRequest: Request = chain.request() 10 | .newBuilder() 11 | .header("User-Agent", userAgent) 12 | .build() 13 | return chain.proceed(userAgentRequest) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/anime_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/drawable/anime_1.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/anime_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/drawable/anime_2.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/anime_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/drawable/anime_3.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/anime_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/drawable/anime_4.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/image_icon.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/index_icon.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/like_icon.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/drawable/logo.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/play_icon.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/subscriptions.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video_icon.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-hdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-mdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-xhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /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/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Iwara 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 21 | 22 |