├── .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 | [](https://github.com/jiangdashao/iwara4a/issues)
3 | [](https://github.com/jiangdashao/iwara4a/network)
4 | [](https://github.com/jiangdashao/iwara4a/stargazers)
5 | [](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 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/test/java/com/rerere/iwara4a/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | buildscript {
3 | ext {
4 | compose_version = '1.0.0-beta08'
5 | hilt_version = '2.36'
6 | }
7 | repositories {
8 | google()
9 | mavenCentral()
10 | }
11 | dependencies {
12 | classpath 'com.android.tools.build:gradle:7.1.0-alpha02'
13 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10"
14 | classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
15 |
16 | // NOTE: Do not place your application dependencies here; they belong
17 | // in the individual module build.gradle files
18 | }
19 | }
20 |
21 | task clean(type: Delete) {
22 | delete rootProject.buildDir
23 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kodeartisan/iwara4a/838810af3222f1ab478ffd6cecf1b03b2e2f9c4d/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Mar 20 14:02:33 CST 2021
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | dependencyResolutionManagement {
2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
3 | repositories {
4 | google()
5 | mavenCentral()
6 | maven { url 'https://jitpack.io' }
7 | jcenter() // Warning: this repository is going to shut down soon
8 | }
9 | }
10 | rootProject.name = "iwara4a"
11 | include ':app'
12 |
--------------------------------------------------------------------------------