├── .github
├── dependabot.yml
├── sign
│ └── iwara4a.jks
└── workflows
│ └── commit.yml
├── .gitignore
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
├── schemas
│ └── com.rerere.iwara4a.data.dao.AppDatabase
│ │ ├── 1.json
│ │ ├── 2.json
│ │ ├── 3.json
│ │ └── 4.json
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── rerere
│ │ └── iwara4a
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── rerere
│ │ │ └── iwara4a
│ │ │ ├── AppContext.kt
│ │ │ ├── data
│ │ │ ├── api
│ │ │ │ ├── IwaraApi.kt
│ │ │ │ ├── IwaraApiImpl.kt
│ │ │ │ ├── Response.kt
│ │ │ │ ├── backend
│ │ │ │ │ └── Iwara4aBackendAPI.kt
│ │ │ │ ├── github
│ │ │ │ │ └── GithubAPI.kt
│ │ │ │ ├── google
│ │ │ │ │ └── TranslatorAPI.kt
│ │ │ │ ├── oreno3d
│ │ │ │ │ └── Oreno3dApi.kt
│ │ │ │ ├── paging
│ │ │ │ │ ├── CommentSource.kt
│ │ │ │ │ ├── LikeSource.kt
│ │ │ │ │ ├── MediaSource.kt
│ │ │ │ │ ├── OrenoSource.kt
│ │ │ │ │ ├── SearchSource.kt
│ │ │ │ │ ├── SubscriptionsSource.kt
│ │ │ │ │ ├── UserMediaSource.kt
│ │ │ │ │ └── UserPageCommentSource.kt
│ │ │ │ └── service
│ │ │ │ │ ├── IwaraParser.kt
│ │ │ │ │ └── IwaraService.kt
│ │ │ ├── dao
│ │ │ │ ├── AppDatabase.kt
│ │ │ │ ├── DownloadedVideoDao.kt
│ │ │ │ ├── FollowUserDao.kt
│ │ │ │ └── HistoryDao.kt
│ │ │ ├── model
│ │ │ │ ├── comment
│ │ │ │ │ ├── CommentList.kt
│ │ │ │ │ └── CommentPostParam.kt
│ │ │ │ ├── detail
│ │ │ │ │ ├── image
│ │ │ │ │ │ └── ImageDetail.kt
│ │ │ │ │ └── video
│ │ │ │ │ │ ├── VideoDetail.kt
│ │ │ │ │ │ ├── VideoDetailFast.kt
│ │ │ │ │ │ └── VideoLink.kt
│ │ │ │ ├── download
│ │ │ │ │ ├── DownloadedVideo.kt
│ │ │ │ │ └── DownloadingVideo.kt
│ │ │ │ ├── flag
│ │ │ │ │ ├── FollowResponse.kt
│ │ │ │ │ └── LikeResponse.kt
│ │ │ │ ├── follow
│ │ │ │ │ └── FollowUser.kt
│ │ │ │ ├── friends
│ │ │ │ │ └── FriendsList.kt
│ │ │ │ ├── github
│ │ │ │ │ └── GithubRelease.kt
│ │ │ │ ├── history
│ │ │ │ │ └── HistoryData.kt
│ │ │ │ ├── index
│ │ │ │ │ ├── MediaList.kt
│ │ │ │ │ ├── MediaPreview.kt
│ │ │ │ │ └── SubscriptionList.kt
│ │ │ │ ├── message
│ │ │ │ │ └── PrivateMessage.kt
│ │ │ │ ├── oreno3d
│ │ │ │ │ └── OrenoPreview.kt
│ │ │ │ ├── playlist
│ │ │ │ │ ├── PlaylistAction.kt
│ │ │ │ │ ├── PlaylistActionResponse.kt
│ │ │ │ │ ├── PlaylistDetail.kt
│ │ │ │ │ ├── PlaylistModifyResponse.kt
│ │ │ │ │ ├── PlaylistOverview.kt
│ │ │ │ │ └── PlaylistPreview.kt
│ │ │ │ ├── session
│ │ │ │ │ ├── Session.kt
│ │ │ │ │ └── SessionManager.kt
│ │ │ │ ├── setting
│ │ │ │ │ └── Setting.kt
│ │ │ │ └── user
│ │ │ │ │ ├── Self.kt
│ │ │ │ │ └── UserData.kt
│ │ │ ├── module
│ │ │ │ ├── NetworkModule.kt
│ │ │ │ └── StorageModule.kt
│ │ │ └── repo
│ │ │ │ ├── MediaRepo.kt
│ │ │ │ └── UserRepo.kt
│ │ │ ├── service
│ │ │ ├── DownloadEntry.kt
│ │ │ └── DownloadService.kt
│ │ │ ├── ui
│ │ │ ├── activity
│ │ │ │ ├── CrashActivity.kt
│ │ │ │ ├── RouterActivity.kt
│ │ │ │ └── RouterViewModel.kt
│ │ │ ├── component
│ │ │ │ ├── Chip.kt
│ │ │ │ ├── CommentItem.kt
│ │ │ │ ├── ComposeWebview.kt
│ │ │ │ ├── EmojiTile.kt
│ │ │ │ ├── IwaraTopBar.kt
│ │ │ │ ├── MaterialDialogState.kt
│ │ │ │ ├── MediaPreview.kt
│ │ │ │ ├── Pager.kt
│ │ │ │ ├── PagerIndicator.kt
│ │ │ │ ├── PagingListIndicator.kt
│ │ │ │ ├── QueryParamSelector.kt
│ │ │ │ ├── RadomLoadingAnim.kt
│ │ │ │ ├── ReplyDialog.kt
│ │ │ │ ├── SmartLinkText.kt
│ │ │ │ ├── basic
│ │ │ │ │ ├── Centered.kt
│ │ │ │ │ └── LazyStaggeredGrid.kt
│ │ │ │ ├── danmu
│ │ │ │ │ ├── Danmaku.kt
│ │ │ │ │ └── DanmakuBox.kt
│ │ │ │ ├── md
│ │ │ │ │ ├── Banner.kt
│ │ │ │ │ ├── ButtonX.kt
│ │ │ │ │ └── TimePickerDialog.kt
│ │ │ │ ├── modifier
│ │ │ │ │ └── ModifierEx.kt
│ │ │ │ ├── paging3
│ │ │ │ │ └── PagingGrid.kt
│ │ │ │ └── player
│ │ │ │ │ ├── PlayerState.kt
│ │ │ │ │ ├── VideoPlayer.kt
│ │ │ │ │ └── VideoPlayerController.kt
│ │ │ ├── local
│ │ │ │ └── LocalValue.kt
│ │ │ ├── screen
│ │ │ │ ├── about
│ │ │ │ │ └── AboutScreen.kt
│ │ │ │ ├── download
│ │ │ │ │ ├── DownloadScreen.kt
│ │ │ │ │ └── DownloadViewModel.kt
│ │ │ │ ├── follow
│ │ │ │ │ ├── FollowScreen.kt
│ │ │ │ │ └── FollowScreenViewModel.kt
│ │ │ │ ├── forum
│ │ │ │ │ └── ForumScreen.kt
│ │ │ │ ├── friends
│ │ │ │ │ ├── FriendsScreen.kt
│ │ │ │ │ └── FriendsViewModel.kt
│ │ │ │ ├── history
│ │ │ │ │ ├── HistoryScreen.kt
│ │ │ │ │ └── HistoryViewModel.kt
│ │ │ │ ├── image
│ │ │ │ │ ├── ImageScreen.kt
│ │ │ │ │ └── ImageViewModel.kt
│ │ │ │ ├── index
│ │ │ │ │ ├── IndexDrawer.kt
│ │ │ │ │ ├── IndexScreen.kt
│ │ │ │ │ ├── IndexViewModel.kt
│ │ │ │ │ └── page
│ │ │ │ │ │ ├── ExplorePage.kt
│ │ │ │ │ │ ├── RankPage.kt
│ │ │ │ │ │ ├── RecommendPage.kt
│ │ │ │ │ │ └── Subage.kt
│ │ │ │ ├── like
│ │ │ │ │ ├── LikeScreen.kt
│ │ │ │ │ └── LikedViewModel.kt
│ │ │ │ ├── log
│ │ │ │ │ └── LogScreen.kt
│ │ │ │ ├── login
│ │ │ │ │ ├── LoginScreen.kt
│ │ │ │ │ └── LoginViewModel.kt
│ │ │ │ ├── message
│ │ │ │ │ ├── MessageScreen.kt
│ │ │ │ │ └── MessageViewModel.kt
│ │ │ │ ├── playlist
│ │ │ │ │ ├── PlaylistDialog.kt
│ │ │ │ │ ├── PlaylistScreen.kt
│ │ │ │ │ ├── PlaylistViewModel.kt
│ │ │ │ │ └── new
│ │ │ │ │ │ ├── PlaylistScreen.kt
│ │ │ │ │ │ └── PlaylistViewModel.kt
│ │ │ │ ├── search
│ │ │ │ │ ├── SearchScreen.kt
│ │ │ │ │ └── SearchViewModel.kt
│ │ │ │ ├── self
│ │ │ │ │ ├── SelfScreen.kt
│ │ │ │ │ └── SelfViewModel.kt
│ │ │ │ ├── setting
│ │ │ │ │ └── SettingScreen.kt
│ │ │ │ ├── splash
│ │ │ │ │ ├── SplashScreen.kt
│ │ │ │ │ └── SplashViewModel.kt
│ │ │ │ ├── test
│ │ │ │ │ └── TestScreen.kt
│ │ │ │ ├── user
│ │ │ │ │ ├── UserScreen.kt
│ │ │ │ │ └── UserViewModel.kt
│ │ │ │ └── video
│ │ │ │ │ ├── VideoScreen.kt
│ │ │ │ │ ├── VideoViewModel.kt
│ │ │ │ │ └── tabs
│ │ │ │ │ ├── VideoScreenCommentTab.kt
│ │ │ │ │ ├── VideoScreenDetailTab.kt
│ │ │ │ │ └── VideoScreenSimilarVideoTab.kt
│ │ │ ├── states
│ │ │ │ ├── ClipboardState.kt
│ │ │ │ ├── IntentState.kt
│ │ │ │ ├── LifecycleState.kt
│ │ │ │ ├── PIPModeState.kt
│ │ │ │ └── ServiceState.kt
│ │ │ ├── theme
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Theme.kt
│ │ │ │ └── Type.kt
│ │ │ └── util
│ │ │ │ ├── FastRememberState.kt
│ │ │ │ ├── GridCell.kt
│ │ │ │ ├── MemorySaver.kt
│ │ │ │ ├── PaddingExtension.kt
│ │ │ │ ├── PreviewAll.kt
│ │ │ │ └── StringResourceByName.kt
│ │ │ └── util
│ │ │ ├── AutoRetry.kt
│ │ │ ├── ComposeHacking.kt
│ │ │ ├── ContextUtil.kt
│ │ │ ├── DataState.kt
│ │ │ ├── DownloadUtil.kt
│ │ │ ├── LogUtil.kt
│ │ │ ├── Maths.kt
│ │ │ ├── TimeUtil.kt
│ │ │ ├── VideoCache.kt
│ │ │ └── okhttp
│ │ │ ├── CookieJarHelper.kt
│ │ │ ├── HtmlFormatUtil.kt
│ │ │ ├── OkhttpUtil.kt
│ │ │ ├── RetryInterceptor.kt
│ │ │ ├── SmartDns.kt
│ │ │ └── UserAgentInterceptor.kt
│ └── res
│ │ ├── drawable-night
│ │ └── ducky_foreground.xml
│ │ ├── drawable
│ │ ├── anime_1.jpg
│ │ ├── anime_2.jpg
│ │ ├── anime_3.jpg
│ │ ├── anime_4.jpg
│ │ ├── compose_logo.png
│ │ ├── download.png
│ │ ├── ducky_foreground.xml
│ │ ├── failed.png
│ │ ├── image_icon.xml
│ │ ├── like_icon.xml
│ │ ├── logo.png
│ │ ├── miku.gif
│ │ ├── onion.xml
│ │ ├── outline_discord_20.xml
│ │ ├── outline_discord_24.xml
│ │ ├── placeholder.png
│ │ ├── play_icon.xml
│ │ ├── upzhu.xml
│ │ ├── video_icon.xml
│ │ └── view_icon.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ducky.xml
│ │ └── ducky_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ducky.png
│ │ └── ducky_round.png
│ │ ├── mipmap-mdpi
│ │ ├── ducky.png
│ │ └── ducky_round.png
│ │ ├── mipmap-xhdpi
│ │ ├── ducky.png
│ │ └── ducky_round.png
│ │ ├── mipmap-xxhdpi
│ │ ├── ducky.png
│ │ └── ducky_round.png
│ │ ├── mipmap-xxxhdpi
│ │ ├── ducky.png
│ │ └── ducky_round.png
│ │ ├── raw
│ │ ├── chip.json
│ │ ├── error.json
│ │ ├── fading_cubes_loader.json
│ │ ├── fan_anim.json
│ │ ├── loading_anim_2.json
│ │ ├── loading_circles.json
│ │ └── niko.json
│ │ ├── values-ja-rJP
│ │ └── strings.xml
│ │ ├── values-night
│ │ └── themes.xml
│ │ ├── values-zh-rCN
│ │ └── strings.xml
│ │ ├── values-zh-rTW
│ │ └── strings.xml
│ │ ├── values
│ │ ├── colors.xml
│ │ ├── ducky_background.xml
│ │ ├── strings.xml
│ │ └── themes.xml
│ │ ├── xml-v25
│ │ └── shortcut.xml
│ │ └── xml
│ │ ├── network_security_config.xml
│ │ └── provider_paths.xml
│ └── test
│ └── java
│ └── com
│ └── rerere
│ └── iwara4a
│ └── ExampleUnitTest.kt
├── art
├── doc
│ └── README_EN.md
├── index.png
├── play.png
└── search.png
├── build.gradle
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gradle"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
--------------------------------------------------------------------------------
/.github/sign/iwara4a.jks:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/.github/sign/iwara4a.jks
--------------------------------------------------------------------------------
/.github/workflows/commit.yml:
--------------------------------------------------------------------------------
1 | name: "Build Commit"
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: actions/setup-java@v3
15 | with:
16 | distribution: adopt
17 | java-version: 11
18 |
19 | - run: bash ./gradlew assembleRelease
20 |
21 | - uses: actions/upload-artifact@v3
22 | with:
23 | name: ${{ github.sha }}
24 | path: app/build/outputs/apk/release/*.apk
25 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | # Iwara4A
3 | [](https://github.com/jiangdashao/iwara4a/issues)
4 | [](https://github.com/jiangdashao/iwara4a/network)
5 | [](https://github.com/jiangdashao/iwara4a/stargazers)
6 | [](https://github.com/jiangdashao/iwara4a)
7 | 
8 | [[English Document]](/art/doc/README_EN.md)
9 |
10 | 基于Jetpack Compose开发的 [iwara](https://iwara.tv) 第三方安卓客户端, 采用Material You设计, 支持安卓6.0以上版本, 完全无多余权限请求
11 |
12 | > APP使用 JSoup/Retrofit 解析I站网页,提取数据并渲染为安卓原生界面,I站**任何内容与本APP无关**,app仅仅承担浏览器的功能
13 | > 使用请遵守你所在地区法律,请勿在**任何渠道**公开传播该APP
14 |
15 | ## ⬇ 下载
16 | https://github.com/re-ovo/iwara4a/releases/latest
17 |
18 | ## 截图
19 | | 主页 | 播放页 | 搜索 |
20 | |-----------------------------------------------------|----------------------------------------------------|------------------------------------------------------|
21 | |
|
|
|
22 |
23 | ## 🚩 功能/Feature
24 | * Material You设计
25 | * 暴力自动重连
26 | * 登录/查看个人信息
27 | * 浏览订阅更新列表
28 | * 播放视频
29 | * 查看图片
30 | * 查看评论
31 | * 点赞
32 | * 关注
33 | * 评论
34 | * 分享
35 | * 搜索
36 | * 榜单
37 | * 下载
38 |
39 | ## 🧭 Questions/常见问题
40 | * **为什么不能查看自己关注了哪些人?**
41 | 答: 因为Iwara网站端禁用了这个功能,据说是因为这个功能会导致数据库负载增大导致网站宕机,如果以后iwara重新开放这个功能,我会加上的
42 |
43 | * **APP支持哪些安卓版本?**
44 | 答: 目前支持Android 6.0 以上的所有版本
45 |
46 | ## 开源协议
47 | ```text
48 | Licensed under the Apache License, Version 2.0 (the "License");
49 | you may not use this file except in compliance with the License.
50 | You may obtain a copy of the License at
51 |
52 | http://www.apache.org/licenses/LICENSE-2.0
53 |
54 | Unless required by applicable law or agreed to in writing, software
55 | distributed under the License is distributed on an "AS IS" BASIS,
56 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
57 | See the License for the specific language governing permissions and
58 | limitations under the License.
59 | ```
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # Keep attributes
9 | -keepattributes SourceFile
10 | -keepattributes *Annotation*
11 |
12 | # Disable obfuscate
13 | -dontobfuscate
14 |
15 | # Keep Model
16 | -keep class com.rerere.iwara4a.data.model.** { *; }
17 | -keepclasseswithmembers class com.rerere.iwara4a.** {
18 | public ** component1();
19 | ;
20 | }
21 |
22 | # Disable ServiceLoader reproducibility-breaking optimizations
23 | -keep class kotlinx.coroutines.CoroutineExceptionHandler
24 | -keep class kotlinx.coroutines.internal.MainDispatcherFactory
25 |
26 | # Keep Aria
27 | -keep class com.arialyy.aria.**{*;}
28 | -keep class **$$DownloadListenerProxy{ *; }
29 | -keep class **$$UploadListenerProxy{ *; }
30 | -keep class **$$DownloadGroupListenerProxy{ *; }
31 | -keep class **$$DGSubListenerProxy{ *; }
32 | -keepclasseswithmembernames class * {
33 | @Download.* ;
34 | @Upload.* ;
35 | @DownloadGroup.* ;
36 | }
--------------------------------------------------------------------------------
/app/schemas/com.rerere.iwara4a.data.dao.AppDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "39bd60ad11ed80b90bdae318ebd7ce64",
6 | "entities": [
7 | {
8 | "tableName": "DownloadedVideo",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nid` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `title` TEXT NOT NULL, `downloadDate` INTEGER NOT NULL, `preview` TEXT NOT NULL, PRIMARY KEY(`nid`))",
10 | "fields": [
11 | {
12 | "fieldPath": "nid",
13 | "columnName": "nid",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "fileName",
19 | "columnName": "fileName",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "title",
25 | "columnName": "title",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "downloadDate",
31 | "columnName": "downloadDate",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "preview",
37 | "columnName": "preview",
38 | "affinity": "TEXT",
39 | "notNull": true
40 | }
41 | ],
42 | "primaryKey": {
43 | "columnNames": [
44 | "nid"
45 | ],
46 | "autoGenerate": false
47 | },
48 | "indices": [],
49 | "foreignKeys": []
50 | }
51 | ],
52 | "views": [],
53 | "setupQueries": [
54 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
55 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '39bd60ad11ed80b90bdae318ebd7ce64')"
56 | ]
57 | }
58 | }
--------------------------------------------------------------------------------
/app/schemas/com.rerere.iwara4a.data.dao.AppDatabase/2.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 2,
5 | "identityHash": "18bab31346dd09bdca777ad646c44b9d",
6 | "entities": [
7 | {
8 | "tableName": "DownloadedVideo",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nid` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `title` TEXT NOT NULL, `downloadDate` INTEGER NOT NULL, `preview` TEXT NOT NULL, `size` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`nid`))",
10 | "fields": [
11 | {
12 | "fieldPath": "nid",
13 | "columnName": "nid",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "fileName",
19 | "columnName": "fileName",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "title",
25 | "columnName": "title",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "downloadDate",
31 | "columnName": "downloadDate",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "preview",
37 | "columnName": "preview",
38 | "affinity": "TEXT",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "size",
43 | "columnName": "size",
44 | "affinity": "INTEGER",
45 | "notNull": true,
46 | "defaultValue": "0"
47 | }
48 | ],
49 | "primaryKey": {
50 | "columnNames": [
51 | "nid"
52 | ],
53 | "autoGenerate": false
54 | },
55 | "indices": [],
56 | "foreignKeys": []
57 | }
58 | ],
59 | "views": [],
60 | "setupQueries": [
61 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
62 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '18bab31346dd09bdca777ad646c44b9d')"
63 | ]
64 | }
65 | }
--------------------------------------------------------------------------------
/app/schemas/com.rerere.iwara4a.data.dao.AppDatabase/3.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 3,
5 | "identityHash": "a1c60bf6d04b4f9ee6810e8e133dae87",
6 | "entities": [
7 | {
8 | "tableName": "DownloadedVideo",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nid` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `title` TEXT NOT NULL, `downloadDate` INTEGER NOT NULL, `preview` TEXT NOT NULL, `size` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`nid`))",
10 | "fields": [
11 | {
12 | "fieldPath": "nid",
13 | "columnName": "nid",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "fileName",
19 | "columnName": "fileName",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "title",
25 | "columnName": "title",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "downloadDate",
31 | "columnName": "downloadDate",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "preview",
37 | "columnName": "preview",
38 | "affinity": "TEXT",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "size",
43 | "columnName": "size",
44 | "affinity": "INTEGER",
45 | "notNull": true,
46 | "defaultValue": "0"
47 | }
48 | ],
49 | "primaryKey": {
50 | "columnNames": [
51 | "nid"
52 | ],
53 | "autoGenerate": false
54 | },
55 | "indices": [],
56 | "foreignKeys": []
57 | },
58 | {
59 | "tableName": "HistoryData",
60 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` INTEGER NOT NULL, `route` TEXT NOT NULL, `preview` TEXT NOT NULL, `title` TEXT NOT NULL, `historyType` TEXT NOT NULL, PRIMARY KEY(`date`))",
61 | "fields": [
62 | {
63 | "fieldPath": "date",
64 | "columnName": "date",
65 | "affinity": "INTEGER",
66 | "notNull": true
67 | },
68 | {
69 | "fieldPath": "route",
70 | "columnName": "route",
71 | "affinity": "TEXT",
72 | "notNull": true
73 | },
74 | {
75 | "fieldPath": "preview",
76 | "columnName": "preview",
77 | "affinity": "TEXT",
78 | "notNull": true
79 | },
80 | {
81 | "fieldPath": "title",
82 | "columnName": "title",
83 | "affinity": "TEXT",
84 | "notNull": true
85 | },
86 | {
87 | "fieldPath": "historyType",
88 | "columnName": "historyType",
89 | "affinity": "TEXT",
90 | "notNull": true
91 | }
92 | ],
93 | "primaryKey": {
94 | "columnNames": [
95 | "date"
96 | ],
97 | "autoGenerate": false
98 | },
99 | "indices": [],
100 | "foreignKeys": []
101 | }
102 | ],
103 | "views": [],
104 | "setupQueries": [
105 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
106 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1c60bf6d04b4f9ee6810e8e133dae87')"
107 | ]
108 | }
109 | }
--------------------------------------------------------------------------------
/app/schemas/com.rerere.iwara4a.data.dao.AppDatabase/4.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 4,
5 | "identityHash": "20705d41a2ec7d23c0b1ef539702def4",
6 | "entities": [
7 | {
8 | "tableName": "DownloadedVideo",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nid` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `title` TEXT NOT NULL, `downloadDate` INTEGER NOT NULL, `preview` TEXT NOT NULL, `size` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`nid`))",
10 | "fields": [
11 | {
12 | "fieldPath": "nid",
13 | "columnName": "nid",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "fileName",
19 | "columnName": "fileName",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "title",
25 | "columnName": "title",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "downloadDate",
31 | "columnName": "downloadDate",
32 | "affinity": "INTEGER",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "preview",
37 | "columnName": "preview",
38 | "affinity": "TEXT",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "size",
43 | "columnName": "size",
44 | "affinity": "INTEGER",
45 | "notNull": true,
46 | "defaultValue": "0"
47 | }
48 | ],
49 | "primaryKey": {
50 | "columnNames": [
51 | "nid"
52 | ],
53 | "autoGenerate": false
54 | },
55 | "indices": [],
56 | "foreignKeys": []
57 | },
58 | {
59 | "tableName": "HistoryData",
60 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` INTEGER NOT NULL, `route` TEXT NOT NULL, `preview` TEXT NOT NULL, `title` TEXT NOT NULL, `historyType` TEXT NOT NULL, PRIMARY KEY(`date`))",
61 | "fields": [
62 | {
63 | "fieldPath": "date",
64 | "columnName": "date",
65 | "affinity": "INTEGER",
66 | "notNull": true
67 | },
68 | {
69 | "fieldPath": "route",
70 | "columnName": "route",
71 | "affinity": "TEXT",
72 | "notNull": true
73 | },
74 | {
75 | "fieldPath": "preview",
76 | "columnName": "preview",
77 | "affinity": "TEXT",
78 | "notNull": true
79 | },
80 | {
81 | "fieldPath": "title",
82 | "columnName": "title",
83 | "affinity": "TEXT",
84 | "notNull": true
85 | },
86 | {
87 | "fieldPath": "historyType",
88 | "columnName": "historyType",
89 | "affinity": "TEXT",
90 | "notNull": true
91 | }
92 | ],
93 | "primaryKey": {
94 | "columnNames": [
95 | "date"
96 | ],
97 | "autoGenerate": false
98 | },
99 | "indices": [],
100 | "foreignKeys": []
101 | },
102 | {
103 | "tableName": "following_user",
104 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idKey` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `profilePic` TEXT NOT NULL)",
105 | "fields": [
106 | {
107 | "fieldPath": "idKey",
108 | "columnName": "idKey",
109 | "affinity": "INTEGER",
110 | "notNull": true
111 | },
112 | {
113 | "fieldPath": "id",
114 | "columnName": "id",
115 | "affinity": "TEXT",
116 | "notNull": true
117 | },
118 | {
119 | "fieldPath": "name",
120 | "columnName": "name",
121 | "affinity": "TEXT",
122 | "notNull": true
123 | },
124 | {
125 | "fieldPath": "profilePic",
126 | "columnName": "profilePic",
127 | "affinity": "TEXT",
128 | "notNull": true
129 | }
130 | ],
131 | "primaryKey": {
132 | "columnNames": [
133 | "idKey"
134 | ],
135 | "autoGenerate": true
136 | },
137 | "indices": [],
138 | "foreignKeys": []
139 | }
140 | ],
141 | "views": [],
142 | "setupQueries": [
143 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
144 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '20705d41a2ec7d23c0b1ef539702def4')"
145 | ]
146 | }
147 | }
--------------------------------------------------------------------------------
/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 |
8 |
9 |
10 |
11 |
12 |
22 |
25 |
26 |
32 |
33 |
38 |
39 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
80 |
81 |
82 |
87 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/Response.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api
2 |
3 | import com.rerere.iwara4a.util.DataState
4 |
5 | sealed class Response(
6 | private val data: T? = null,
7 | private val errorMessage: String? = null
8 | ) {
9 | companion object {
10 | fun success(data: T) = Success(data)
11 | fun failed(errorMessage: String = "null") = Failed(errorMessage)
12 | }
13 |
14 | fun isSuccess() = this is Success
15 | fun isFailed() = this is Failed
16 |
17 | fun read() = data!!
18 | fun errorMessage() = errorMessage!!
19 |
20 | fun toDataState() = if(isSuccess()) DataState.Success(read()) else DataState.Error(errorMessage())
21 |
22 | class Success internal constructor(data: T) : Response(data = data)
23 | class Failed internal constructor(errorMessage: String) :
24 | Response(errorMessage = errorMessage)
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/backend/Iwara4aBackendAPI.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.backend
2 |
3 | import com.rerere.iwara4a.data.model.detail.video.VideoDetailFast
4 | import retrofit2.http.GET
5 | import retrofit2.http.Path
6 | import retrofit2.http.Query
7 | import java.util.UUID
8 |
9 | interface Iwara4aBackendAPI {
10 | @GET("/stats")
11 | suspend fun postStatusData(
12 | @Query("uuid") uuid: String
13 | )
14 |
15 | @GET("/video/{id}")
16 | suspend fun fetchVideoDetail(@Path("id") id: String): VideoDetailFast
17 |
18 | @GET("/broadcast")
19 | suspend fun getBroadcastMessage(): List
20 |
21 | @GET("/recommend_tags")
22 | suspend fun getAllRecommendTags(): List
23 |
24 | @GET("/recommend")
25 | suspend fun recommend(
26 | @Query("tags") tags: String,
27 | @Query("limit") limit: Int
28 | ): List
29 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/github/GithubAPI.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.github
2 |
3 | import android.util.Log
4 | import com.google.gson.Gson
5 | import com.rerere.iwara4a.data.api.Response
6 | import com.rerere.iwara4a.data.model.github.GithubRelease
7 | import com.rerere.iwara4a.util.okhttp.await
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.withContext
10 | import okhttp3.OkHttpClient
11 | import okhttp3.Request
12 | import javax.inject.Inject
13 |
14 | private const val TAG = "GithubAPI"
15 |
16 | class GithubAPI @Inject constructor(
17 | val okHttpClient: OkHttpClient
18 | ) {
19 | suspend fun getLatestRelease(): Response = withContext(Dispatchers.IO) {
20 | try {
21 | Log.i(TAG, "getLatestRelease: Checking update...")
22 |
23 | val request = Request.Builder()
24 | .url("https://api.github.com/repos/re-ovo/iwara4a/releases/latest")
25 | .get()
26 | .build()
27 |
28 | val response = okHttpClient.newCall(request).await()
29 | require(response.isSuccessful)
30 | require(response.body != null)
31 | val githubRelease = Gson().fromJson(response.body?.string(), GithubRelease::class.java)
32 | Response.success(githubRelease)
33 | } catch (e: Exception) {
34 | e.printStackTrace()
35 | Response.failed(e.javaClass.simpleName)
36 | }
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/google/TranslatorAPI.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.google
2 |
3 | import com.google.gson.JsonParser
4 | import com.rerere.iwara4a.util.okhttp.await
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.withContext
7 | import okhttp3.OkHttpClient
8 | import okhttp3.Request
9 | import java.util.*
10 | import javax.inject.Inject
11 |
12 | class TranslatorAPI @Inject constructor(
13 | private val httpClient: OkHttpClient
14 | ) {
15 | suspend fun translate(text: String): String? {
16 | val targetLanguage = when (Locale.getDefault().language) {
17 | Locale.SIMPLIFIED_CHINESE.language -> "zh"
18 | Locale.JAPANESE.language -> "ja"
19 | Locale.KOREAN.language -> "ko"
20 | else -> "en"
21 | }
22 | return withContext(Dispatchers.IO) {
23 | val request = Request.Builder()
24 | .url("https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=$targetLanguage&dt=t&q=$text")
25 | .get()
26 | .build()
27 | try {
28 | val response = httpClient.newCall(request).await()
29 | val result = JsonParser().parse(response.body!!.string())
30 | .asJsonArray[0]
31 | .asJsonArray
32 | .joinToString(
33 | separator = ""
34 | ) {
35 | it.asJsonArray[0].asString
36 | }
37 | result
38 | } catch (e: Exception) {
39 | e.printStackTrace()
40 | null
41 | }
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/paging/CommentSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import com.rerere.iwara4a.data.model.comment.Comment
6 | import com.rerere.iwara4a.data.model.index.MediaType
7 | import com.rerere.iwara4a.data.model.session.SessionManager
8 | import com.rerere.iwara4a.data.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/data/api/paging/LikeSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import android.util.Log
4 | import androidx.paging.PagingSource
5 | import androidx.paging.PagingState
6 | import com.rerere.iwara4a.data.model.index.MediaPreview
7 | import com.rerere.iwara4a.data.model.session.SessionManager
8 | import com.rerere.iwara4a.data.repo.MediaRepo
9 |
10 | private const val TAG = "LikeSource"
11 |
12 | @Deprecated("Use PageList component instead")
13 | class LikeSource(
14 | private val mediaRepo: MediaRepo,
15 | private val sessionManager: SessionManager
16 | ) : PagingSource() {
17 | override fun getRefreshKey(state: PagingState): Int {
18 | return 0
19 | }
20 |
21 | override suspend fun load(params: LoadParams): LoadResult {
22 | val page = params.key ?: 0
23 |
24 | Log.i(TAG, "load: Trying to load like list: $page")
25 |
26 | val response = mediaRepo.getLikePage(sessionManager.session, page)
27 | return if (response.isSuccess()) {
28 | val data = response.read()
29 | Log.i(
30 | TAG,
31 | "load: Success load like list (datasize=${data.mediaList.size}, hasNext=${data.hasNext})"
32 | )
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/data/api/paging/MediaSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import android.util.Log
4 | import androidx.paging.PagingSource
5 | import androidx.paging.PagingState
6 | import com.rerere.iwara4a.data.model.index.MediaPreview
7 | import com.rerere.iwara4a.data.model.index.MediaType
8 | import com.rerere.iwara4a.data.model.session.SessionManager
9 | import com.rerere.iwara4a.data.repo.MediaRepo
10 |
11 | private const val TAG = "MediaSource"
12 |
13 | @Deprecated("Use PageList component instead")
14 | class MediaSource(
15 | private val mediaType: MediaType,
16 | private val mediaRepo: MediaRepo,
17 | private val sessionManager: SessionManager
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 | Log.i(TAG, "load: Trying to load media list: $page")
27 |
28 | val response = mediaRepo.getMediaList(
29 | sessionManager.session,
30 | mediaType,
31 | page
32 | )
33 | return if (response.isSuccess()) {
34 | val data = response.read()
35 | Log.i(
36 | TAG,
37 | "load: Success load media list (datasize=${data.mediaList.size}, hasNext=${data.hasNext})"
38 | )
39 | LoadResult.Page(
40 | data = data.mediaList,
41 | prevKey = if (page <= 0) null else page - 1,
42 | nextKey = if (data.hasNext) page + 1 else null
43 | )
44 | } else {
45 | LoadResult.Error(Exception(response.errorMessage()))
46 | }
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/paging/OrenoSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import android.util.Log
4 | import androidx.paging.PagingSource
5 | import androidx.paging.PagingState
6 | import com.rerere.iwara4a.data.api.oreno3d.Oreno3dApi
7 | import com.rerere.iwara4a.data.api.oreno3d.OrenoSort
8 | import com.rerere.iwara4a.data.model.oreno3d.OrenoPreview
9 |
10 | private const val TAG = "OrenoSource"
11 |
12 | class OrenoSource(
13 | private val oreno3dApi: Oreno3dApi,
14 | private val orenoSort: OrenoSort
15 | ) : PagingSource() {
16 | override fun getRefreshKey(state: PagingState): Int {
17 | return 1
18 | }
19 |
20 | override suspend fun load(params: LoadParams): LoadResult {
21 | val page = params.key ?: 1
22 |
23 | Log.i(TAG, "load: load list (page: $page, sort: ${orenoSort.value})")
24 |
25 | val response = oreno3dApi.getVideoList(
26 | page = page,
27 | sort = orenoSort
28 | )
29 |
30 | return if(response.isSuccess()){
31 | LoadResult.Page(
32 | data = response.read().list,
33 | prevKey = if(page > 1) page - 1 else null,
34 | nextKey = if(response.read().hasNext) page + 1 else null
35 | )
36 | }else {
37 | LoadResult.Error(throwable = Throwable(response.errorMessage()))
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/paging/SearchSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import android.util.Log
4 | import androidx.paging.PagingSource
5 | import androidx.paging.PagingState
6 | import com.rerere.iwara4a.data.model.index.MediaPreview
7 | import com.rerere.iwara4a.data.model.session.SessionManager
8 | import com.rerere.iwara4a.data.repo.MediaRepo
9 | import com.rerere.iwara4a.ui.component.SortType
10 |
11 | private const val TAG = "SearchSource"
12 |
13 | @Deprecated("Use PageList component instead")
14 | class SearchSource(
15 | private val mediaRepo: MediaRepo,
16 | private val sessionManager: SessionManager,
17 | private val query: String
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 | SortType.LIKES,
41 | hashSetOf()
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/data/api/paging/SubscriptionsSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import android.util.Log
4 | import androidx.compose.runtime.MutableState
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.paging.PagingSource
7 | import androidx.paging.PagingState
8 | import com.rerere.iwara4a.data.model.index.MediaPreview
9 | import com.rerere.iwara4a.data.model.session.SessionManager
10 | import com.rerere.iwara4a.data.repo.MediaRepo
11 |
12 | private const val TAG = "SubscriptionsSource"
13 |
14 | @Deprecated("Use PageList component instead")
15 | class SubscriptionsSource(
16 | private val sessionManager: SessionManager,
17 | private val mediaRepo: MediaRepo,
18 | private val loadPage: MutableState = mutableStateOf(0)
19 | ) : PagingSource() {
20 | override fun getRefreshKey(state: PagingState): Int {
21 | return loadPage.value.coerceAtLeast(0)
22 | }
23 |
24 | override suspend fun load(params: LoadParams): LoadResult {
25 | val page = params.key ?: loadPage.value.coerceAtLeast(0)
26 |
27 | Log.i(TAG, "load: trying to load page: $page")
28 |
29 | val response = mediaRepo.getSubscriptionList(sessionManager.session, page)
30 | return if (response.isSuccess()) {
31 | val data = response.read()
32 | Log.i(
33 | TAG,
34 | "load: success load sub list (datasize=${data.subscriptionList.size}, hasNext=${data.hasNextPage})"
35 | )
36 | LoadResult.Page(
37 | data = data.subscriptionList,
38 | prevKey = null,// if (page <= 0) null else page - 1,
39 | nextKey = if (data.hasNextPage) page + 1 else null
40 | )
41 | } else {
42 | LoadResult.Error(Exception(response.errorMessage()))
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/paging/UserMediaSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import com.rerere.iwara4a.data.model.index.MediaPreview
6 | import com.rerere.iwara4a.data.model.session.SessionManager
7 | import com.rerere.iwara4a.data.repo.MediaRepo
8 |
9 | class UserVideoListSource(
10 | private val mediaRepo: MediaRepo,
11 | private val sessionManager: SessionManager,
12 | private val userId: String
13 | ) : PagingSource() {
14 | override fun getRefreshKey(state: PagingState): Int {
15 | return 0
16 | }
17 |
18 | override suspend fun load(params: LoadParams): LoadResult {
19 | val page = params.key ?: 0
20 | val response = mediaRepo.getUserVideoList(sessionManager.session, userId, page)
21 | return if (response.isSuccess()) {
22 | val data = response.read()
23 | LoadResult.Page(
24 | data = data.mediaList,
25 | prevKey = if (page <= 0) null else page - 1,
26 | nextKey = if (data.hasNext) page + 1 else null
27 | )
28 | } else {
29 | LoadResult.Error(Exception(response.errorMessage()))
30 | }
31 | }
32 | }
33 |
34 | class UserImageListSource(
35 | private val mediaRepo: MediaRepo,
36 | private val sessionManager: SessionManager,
37 | private val userId: String
38 | ) : PagingSource() {
39 | override fun getRefreshKey(state: PagingState): Int {
40 | return 0
41 | }
42 |
43 | override suspend fun load(params: LoadParams): LoadResult {
44 | val page = params.key ?: 0
45 | val response = mediaRepo.getUserImageList(sessionManager.session, userId, page)
46 | return if (response.isSuccess()) {
47 | val data = response.read()
48 | LoadResult.Page(
49 | data = data.mediaList,
50 | prevKey = if (page <= 0) null else page - 1,
51 | nextKey = if (data.hasNext) page + 1 else null
52 | )
53 | } else {
54 | LoadResult.Error(Exception(response.errorMessage()))
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/paging/UserPageCommentSource.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.paging
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import com.rerere.iwara4a.data.model.comment.Comment
6 | import com.rerere.iwara4a.data.model.session.SessionManager
7 | import com.rerere.iwara4a.data.repo.UserRepo
8 |
9 | class UserPageCommentSource(
10 | private val sessionManager: SessionManager,
11 | private val userRepo: UserRepo,
12 | private val userId: String
13 | ) : PagingSource() {
14 | override fun getRefreshKey(state: PagingState): Int? {
15 | return 0
16 | }
17 |
18 | override suspend fun load(params: LoadParams): LoadResult {
19 | val page = params.key ?: 0
20 |
21 | val response = userRepo.getUserPageComment(sessionManager.session, userId, page)
22 |
23 | return if (response.isSuccess()) {
24 | LoadResult.Page(
25 | data = response.read().comments,
26 | prevKey = if (page <= 0) null else page - 1,
27 | nextKey = if (response.read().hasNext) page + 1 else null
28 | )
29 | } else {
30 | LoadResult.Error(Exception(response.errorMessage()))
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/api/service/IwaraService.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.api.service
2 |
3 | import com.rerere.iwara4a.data.model.detail.video.VideoLink
4 | import com.rerere.iwara4a.data.model.playlist.PlaylistActionResponse
5 | import com.rerere.iwara4a.data.model.playlist.PlaylistModifyResponse
6 | import com.rerere.iwara4a.data.model.playlist.PlaylistPreview
7 | import retrofit2.http.*
8 |
9 | /**
10 | * 使用Retrofit直接获取 RESTFUL API 资源
11 | */
12 | interface IwaraService {
13 | @FormUrlEncoded
14 | @POST("api/playlists")
15 | suspend fun createPlaylist(
16 | @Field("title") title: String
17 | ): PlaylistActionResponse
18 |
19 | @POST("api/video/{videoId}")
20 | suspend fun getVideoInfo(@Path("videoId") videoId: String): VideoLink
21 |
22 | @GET("api/playlists")
23 | suspend fun getPlaylistPreview(
24 | @Header("cookie") cookie: String,
25 | @Query("nid") nid: Int
26 | ): PlaylistPreview
27 |
28 | @FormUrlEncoded
29 | @PUT("api/playlists")
30 | suspend fun putToPlaylist(
31 | @Header("cookie") cookie: String,
32 | @Field("nid") nid: Int,
33 | @Field("playlist") playlist: Int
34 | ): PlaylistModifyResponse
35 |
36 | @FormUrlEncoded
37 | @HTTP(method = "DELETE", path = "api/playlists", hasBody = true)
38 | suspend fun deleteFromPlaylist(
39 | @Header("cookie") cookie: String,
40 | @Field("nid") nid: Int,
41 | @Field("playlist") playlist: Int
42 | ): PlaylistModifyResponse
43 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/dao/AppDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.dao
2 |
3 | import androidx.room.AutoMigration
4 | import androidx.room.Database
5 | import androidx.room.RoomDatabase
6 | import com.rerere.iwara4a.data.model.download.DownloadedVideo
7 | import com.rerere.iwara4a.data.model.follow.FollowUser
8 | import com.rerere.iwara4a.data.model.history.HistoryData
9 |
10 | @Database(
11 | entities = [DownloadedVideo::class, HistoryData::class, FollowUser::class],
12 | version = 4,
13 | autoMigrations = [
14 | AutoMigration(from = 1, to = 2),
15 | AutoMigration(from = 2, to = 3),
16 | AutoMigration(from = 3, to = 4)
17 | ]
18 | )
19 | abstract class AppDatabase : RoomDatabase() {
20 | abstract fun getDownloadedVideoDao(): DownloadedVideoDao
21 |
22 | abstract fun getHistoryDao(): HistoryDao
23 |
24 | abstract fun getFollowingDao(): FollowUserDao
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/dao/DownloadedVideoDao.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.dao
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Delete
5 | import androidx.room.Insert
6 | import androidx.room.Query
7 | import com.rerere.iwara4a.data.model.download.DownloadedVideo
8 | import kotlinx.coroutines.flow.Flow
9 |
10 | @Dao
11 | interface DownloadedVideoDao {
12 | @Query("SELECT * FROM downloadedvideo ORDER BY downloadDate DESC")
13 | fun getAllDownloadedVideos(): Flow>
14 |
15 | @Query("SELECT * FROM downloadedvideo WHERE nid=:nid")
16 | suspend fun getVideo(nid: Int): DownloadedVideo?
17 |
18 | @Insert
19 | suspend fun insertVideo(video: DownloadedVideo)
20 |
21 | @Delete
22 | suspend fun delete(video: DownloadedVideo)
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/dao/FollowUserDao.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.dao
2 |
3 | import androidx.room.*
4 | import com.rerere.iwara4a.data.model.follow.FollowUser
5 | import kotlinx.coroutines.flow.Flow
6 |
7 | @Dao
8 | interface FollowUserDao {
9 | @Query("SELECT * FROM following_user")
10 | fun getAllFollowUsers(): Flow>
11 |
12 | @Insert(onConflict = OnConflictStrategy.REPLACE)
13 | suspend fun addUser(user: FollowUser)
14 |
15 | @Delete
16 | suspend fun removeUser(user: FollowUser)
17 |
18 | @Update
19 | suspend fun updateUser(user: FollowUser)
20 |
21 | @Query("SELECT * FROM following_user WHERE id=:id LIMIT 1")
22 | suspend fun getUserById(id: String): FollowUser?
23 | }
24 |
25 | suspend fun FollowUserDao.insertSmart(
26 | id: String,
27 | name: String,
28 | profilePic: String
29 | ){
30 | if(id.isBlank() || name.isBlank() || profilePic.isBlank()){
31 | return
32 | }
33 | val localUser = getUserById(id)
34 | localUser?.let {
35 | updateUser(it.apply {
36 | this.name = name
37 | this.profilePic = profilePic
38 | })
39 | } ?: run {
40 | addUser(
41 | FollowUser(
42 | id = id,
43 | name = name,
44 | profilePic = profilePic
45 | )
46 | )
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/dao/HistoryDao.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.dao
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.Query
6 | import com.rerere.iwara4a.data.model.history.HistoryData
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlin.math.abs
9 |
10 | @Dao
11 | interface HistoryDao {
12 | @Query("SELECT * FROM historydata ORDER BY date DESC")
13 | fun getAllHistory(): Flow>
14 |
15 | @Query("SELECT * FROM historydata ORDER BY date DESC LIMIT 1")
16 | suspend fun getLatestHistory() : HistoryData?
17 |
18 | @Insert
19 | suspend fun insert(historyData: HistoryData)
20 |
21 | @Query("DELETE FROM historydata")
22 | suspend fun clearAll()
23 | }
24 |
25 | /**
26 | * 只在与上一个data不同时或者间隔超过1分钟才插入该数据
27 | */
28 | suspend fun HistoryDao.insertSmartly(historyData: HistoryData){
29 | val latest = getLatestHistory()
30 | if(latest == null || latest.route != historyData.route || abs(latest.date - historyData.date) >= 60000){
31 | insert(historyData)
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/comment/CommentList.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.comment
2 |
3 | import androidx.compose.ui.util.fastForEach
4 |
5 | data class CommentList(
6 | val total: Int,
7 | val page: Int,
8 | val hasNext: Boolean,
9 | val comments: List,
10 | )
11 |
12 | data class Comment(
13 | val authorId: String,
14 | val authorName: String,
15 | val authorPic: String,
16 | val posterType: CommentPosterType,
17 |
18 | val nid: Int,
19 | val commentId: Int,
20 |
21 | val content: String,
22 | val date: String,
23 | val fromIwara4a: Boolean,
24 |
25 | var reply: List
26 | )
27 |
28 | enum class CommentPosterType {
29 | NORMAL,
30 | SELF,
31 | OWNER
32 | }
33 |
34 | fun Comment.getAllReplies(): List {
35 | val list = mutableListOf()
36 | list.addAll(reply)
37 | reply.fastForEach {
38 | list.addAll(it.getAllReplies())
39 | }
40 | return list
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/comment/CommentPostParam.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.comment
2 |
3 | data class CommentPostParam(
4 | val antiBotKey: String,
5 | val formId: String,
6 | val formBuildId: String,
7 | val formToken: String,
8 | val honeypotTime: String
9 | ) {
10 | companion object {
11 | val Default = CommentPostParam(
12 | "",
13 | "",
14 | "",
15 | "",
16 | ""
17 | )
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/detail/image/ImageDetail.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.detail.image
2 |
3 | import com.rerere.iwara4a.data.model.comment.CommentPostParam
4 |
5 | data class ImageDetail(
6 | val id: String,
7 | val nid: Int,
8 | val title: String,
9 | val imageLinks: List,
10 | val description: String,
11 |
12 | val authorId: String,
13 | val authorName: String,
14 | val authorProfilePic: String,
15 |
16 | val watchs: String,
17 |
18 | val commentPostParam: CommentPostParam,
19 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/detail/video/VideoDetail.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.detail.video
2 |
3 | import com.rerere.iwara4a.data.model.comment.CommentPostParam
4 |
5 | data class VideoDetail(
6 | // 视频信息
7 | val id: String,
8 | val nid: Int,
9 | val title: String,
10 | val likes: String,
11 | val watchs: String,
12 | val postDate: String,
13 | val description: String,
14 | val preview: String,
15 |
16 | val comments: Int,
17 | val commentPostParam: CommentPostParam,
18 |
19 | // 视频作者信息
20 | val authorPic: String,
21 | val authorName: String,
22 | val authorId: String,
23 |
24 | // 作者的更多视频
25 | val moreVideo: List,
26 | // 相似视频
27 | val recommendVideo: List,
28 |
29 | // 是否关注
30 | val follow: Boolean,
31 | // 关注链接
32 | val followLink: String,
33 |
34 | // 是否喜欢
35 | var isLike: Boolean,
36 | val likeLink: String
37 | ) {
38 | companion object {
39 | val DELETED = VideoDetail(
40 | "",
41 | 0,
42 | "视频不存在",
43 | "",
44 | "",
45 | "",
46 | "",
47 | "",
48 | 0,
49 | CommentPostParam.Default,
50 | "",
51 | "",
52 | "",
53 | emptyList(),
54 | emptyList(),
55 | true,
56 | "",
57 | true,
58 | ""
59 | )
60 |
61 | val PRIVATE = VideoDetail(
62 | "",
63 | 0,
64 | "私人视频",
65 | "",
66 | "",
67 | "",
68 | "",
69 | "",
70 | 0,
71 | CommentPostParam.Default,
72 | "",
73 | "",
74 | "",
75 | emptyList(),
76 | emptyList(),
77 | true,
78 | "",
79 | true,
80 | ""
81 | )
82 | }
83 | }
84 |
85 | data class MoreVideo(
86 | val id: String,
87 | val title: String,
88 | val pic: String,
89 | val watchs: String,
90 | val likes: String
91 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/detail/video/VideoDetailFast.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.detail.video
2 |
3 | import com.rerere.iwara4a.data.model.index.MediaPreview
4 | import com.rerere.iwara4a.data.model.index.MediaType
5 |
6 | data class VideoDetailFast(
7 | // 视频信息
8 | val id: String,
9 | val nid: Int,
10 | val title: String,
11 | val likes: String,
12 | val watchs: String,
13 | val postDate: String,
14 | val description: String,
15 | val preview: String,
16 |
17 | // 视频作者信息
18 | val authorPic: String,
19 | val authorName: String,
20 | val authorId: String
21 | )
22 |
23 | fun VideoDetailFast.toMediaPreview() = MediaPreview(
24 | title = this.title,
25 | author = this.authorName,
26 | previewPic = this.preview,
27 | likes = this.likes,
28 | watchs = this.watchs,
29 | type = MediaType.VIDEO,
30 | mediaId = this.id,
31 | private = false
32 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/detail/video/VideoLink.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.detail.video
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | class VideoLink: ArrayList() {
6 | fun toLinkMap() : Map = mutableMapOf().apply {
7 | this@VideoLink.forEach { linkItem ->
8 | this[linkItem.resolution] = linkItem.toLink()
9 | }
10 | }
11 | }
12 |
13 | data class VideoLinkItem(
14 | @SerializedName("mime")
15 | val mime: String,
16 | @SerializedName("resolution")
17 | val resolution: String,
18 | @SerializedName("uri")
19 | val uri: String
20 | ) {
21 | fun toLink() = "https:" + unescapeJava(uri).replace("\\/", "/")
22 | }
23 |
24 | private fun unescapeJava(escaped: String): String {
25 | var escaped = escaped
26 | if (escaped.indexOf("\\u") == -1) return escaped
27 | var processed = ""
28 | var position = escaped.indexOf("\\u")
29 | while (position != -1) {
30 | if (position != 0) processed += escaped.substring(0, position)
31 | val token = escaped.substring(position + 2, position + 6)
32 | escaped = escaped.substring(position + 6)
33 | processed += token.toInt(16).toChar()
34 | position = escaped.indexOf("\\u")
35 | }
36 | processed += escaped
37 | return processed
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/download/DownloadedVideo.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.download
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.PrimaryKey
6 |
7 | @Entity
8 | data class DownloadedVideo(
9 | @PrimaryKey val nid: Int,
10 | val fileName: String,
11 | val title: String,
12 | val downloadDate: Long,
13 | val preview: String,
14 | @ColumnInfo(defaultValue = "0") val size: Long
15 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/download/DownloadingVideo.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.download
2 |
3 | data class DownloadingVideo(
4 | val nid: Int,
5 | val fileName: String,
6 | val title: String,
7 | val downloadDate: Long,
8 | val preview: String,
9 | var progress: Float = 0f
10 | ) {
11 | override fun equals(other: Any?): Boolean {
12 | if (this === other) return true
13 | if (javaClass != other?.javaClass) return false
14 |
15 | other as DownloadingVideo
16 |
17 | if (nid != other.nid) return false
18 |
19 | return true
20 | }
21 |
22 | override fun hashCode(): Int {
23 | return nid
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/flag/FollowResponse.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.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/data/model/flag/LikeResponse.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.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/data/model/follow/FollowUser.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.follow
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity(tableName = "following_user")
7 | data class FollowUser(
8 | @PrimaryKey(autoGenerate = true)
9 | val idKey: Int = 0,
10 | val id: String,
11 | var name: String,
12 | var profilePic: String
13 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/friends/FriendsList.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.friends
2 |
3 | typealias FriendList = List
4 |
5 | data class Friend(
6 | val username: String,
7 | val userId: String,
8 | val frId: Int,
9 | val date: String,
10 | val friendStatus: FriendStatus
11 | )
12 |
13 | enum class FriendStatus {
14 | // 等待通过
15 | PENDING,
16 |
17 | // 自己发的好友请求, 等待对方通过
18 | PENDING_REQUEST,
19 |
20 | // 已通过
21 | ACCEPTED,
22 |
23 | // 未知
24 | UNKNOWN
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/history/HistoryData.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.history
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.room.Entity
5 | import androidx.room.PrimaryKey
6 |
7 | @Entity
8 | class HistoryData(
9 | @PrimaryKey val date: Long,
10 | val route: String,
11 | val preview: String,
12 | val title: String,
13 | val historyType: HistoryType
14 | )
15 |
16 | enum class HistoryType {
17 | VIDEO,
18 | IMAGE,
19 | USER
20 | }
21 |
22 | @Composable
23 | fun HistoryType.asString() = when (this) {
24 | HistoryType.VIDEO -> "视频"
25 | HistoryType.IMAGE -> "图片"
26 | HistoryType.USER -> "用户"
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/index/MediaList.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.index
2 |
3 | data class MediaList(
4 | val currentPage: Int,
5 | val hasNext: Boolean,
6 | val mediaList: List
7 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/index/MediaPreview.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.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 | val private: Boolean = false
23 | )
24 |
25 | enum class MediaType(val value: String) {
26 | VIDEO("videos"),
27 | IMAGE("images")
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/index/SubscriptionList.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.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/data/model/message/PrivateMessage.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.message
2 |
3 | // 代表一个对话预览
4 | // 为 https://ecchi.iwara.tv/messages 页面的消息Item
5 | data class PrivateMessagePreview(
6 | val conversationId: Int,
7 | val title: String,
8 | val targetName: String,
9 | val targetId: String,
10 | val lastUpdated: String,
11 | val messages: Int
12 | )
13 |
14 | // 代表私聊对话列表
15 | typealias PrivateMessagePreviewList = List
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/oreno3d/OrenoPreview.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.oreno3d
2 |
3 | data class OrenoPreview(
4 | val title: String,
5 | val author: String,
6 | val pic: String,
7 | val like: String,
8 | val watch: String,
9 | val id: Int
10 | )
11 |
12 | data class OrenoPreviewList(
13 | val list: List,
14 | val currentPage: Int,
15 | val hasNext: Boolean
16 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/playlist/PlaylistAction.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.playlist
2 |
3 | enum class PlaylistAction {
4 | PUT,
5 | DELETE
6 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/playlist/PlaylistActionResponse.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.playlist
2 |
3 |
4 | import com.google.gson.annotations.SerializedName
5 |
6 | data class PlaylistActionResponse(
7 | @SerializedName("message")
8 | val message: String,
9 | @SerializedName("status")
10 | val status: Int
11 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/playlist/PlaylistDetail.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.playlist
2 |
3 | import com.rerere.iwara4a.data.model.index.MediaPreview
4 |
5 | class PlaylistDetail(
6 | val title: String,
7 | val nid: Int,
8 | val videolist: List
9 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/playlist/PlaylistModifyResponse.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.playlist
2 |
3 |
4 | import com.google.gson.annotations.SerializedName
5 |
6 | data class PlaylistModifyResponse(
7 | @SerializedName("message")
8 | val message: String,
9 | @SerializedName("status")
10 | val status: Int
11 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/playlist/PlaylistOverview.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.playlist
2 |
3 | class PlaylistOverview(
4 | val name: String,
5 | val id: String
6 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/playlist/PlaylistPreview.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.playlist
2 |
3 |
4 | import com.google.gson.annotations.SerializedName
5 |
6 | class PlaylistPreview : ArrayList() {
7 | data class PlaylistPreviewItem(
8 | @SerializedName("in_list")
9 | val inList: Int,
10 | @SerializedName("nid")
11 | val nid: String,
12 | @SerializedName("title")
13 | val title: String
14 | ) {
15 | val inIt
16 | get() = inList != 0
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/session/Session.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.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 |
17 | override fun toString(): String {
18 | return "$key=$value"
19 | }
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/session/SessionManager.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.session
2 |
3 | import android.content.Context
4 | import androidx.core.content.edit
5 | import com.rerere.iwara4a.sharedPreferencesOf
6 |
7 | class SessionManager(private val context: Context) {
8 | val session: Session by lazy {
9 | val sharedPreferences = context.sharedPreferencesOf("session")
10 | Session(
11 | sharedPreferences.getString("key", "")!!,
12 | sharedPreferences.getString("value", "")!!
13 | )
14 | }
15 |
16 | fun update(key: String, value: String) {
17 | session.key = key
18 | session.value = value
19 | context.sharedPreferencesOf("session").edit {
20 | putString("key", key)
21 | putString("value", value)
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/setting/Setting.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.setting
2 |
3 | data class Setting(
4 | val themeSetting: Theme
5 | ) {
6 | data class Theme(
7 | var mode: Int
8 | )
9 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/user/Self.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.user
2 |
3 | data class Self(
4 | val id: String,
5 | val numId: Int,
6 | val nickname: String,
7 | val profilePic: String,
8 | val about: String? = null,
9 | val friendRequest: Int = 0,
10 | val messages: Int = 0
11 | ) {
12 | companion object {
13 | val GUEST = Self("", 0, "访客", "https://ecchi.iwara.tv/sites/all/themes/main/img/logo.png")
14 | }
15 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/model/user/UserData.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.model.user
2 |
3 | import com.rerere.iwara4a.data.model.comment.CommentPostParam
4 |
5 | data class UserData(
6 | val userId: String,
7 | val username: String,
8 | val userIdMedia: String,
9 |
10 | val follow: Boolean,
11 | val followLink: String,
12 |
13 | val friend: UserFriendState,
14 | val id: Int,
15 |
16 | val pic: String,
17 | val joinDate: String,
18 | val lastSeen: String,
19 | val about: String,
20 |
21 | val commentId: Int,
22 | val commentPostParam: CommentPostParam
23 | ) {
24 | companion object {
25 | val LOADING = UserData(
26 | "",
27 | "",
28 | "",
29 | false,
30 | "",
31 | UserFriendState.NOT,
32 | 0,
33 | "",
34 | "",
35 | "",
36 | "",
37 | 0,
38 | CommentPostParam.Default
39 | )
40 | }
41 | }
42 |
43 | enum class UserFriendState {
44 | NOT,
45 | PENDING,
46 | ALREADY
47 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/module/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.module
2 |
3 | import com.rerere.iwara4a.data.api.IwaraApi
4 | import com.rerere.iwara4a.data.api.IwaraApiImpl
5 | import com.rerere.iwara4a.data.api.backend.Iwara4aBackendAPI
6 | import com.rerere.iwara4a.data.api.oreno3d.Oreno3dApi
7 | import com.rerere.iwara4a.data.api.service.IwaraParser
8 | import com.rerere.iwara4a.data.api.service.IwaraService
9 | import com.rerere.iwara4a.util.okhttp.CookieJarHelper
10 | import com.rerere.iwara4a.util.okhttp.Retry
11 | import com.rerere.iwara4a.util.okhttp.SmartDns
12 | import com.rerere.iwara4a.util.okhttp.UserAgentInterceptor
13 | import dagger.Module
14 | import dagger.Provides
15 | import dagger.hilt.InstallIn
16 | import dagger.hilt.components.SingletonComponent
17 | import okhttp3.OkHttpClient
18 | import retrofit2.Retrofit
19 | import retrofit2.converter.gson.GsonConverterFactory
20 | import java.util.concurrent.TimeUnit
21 | import javax.inject.Singleton
22 |
23 | // Time out
24 | private const val TIMEOUT = 15_000L
25 |
26 | @Module
27 | @InstallIn(SingletonComponent::class)
28 | object NetworkModule {
29 | @Provides
30 | @Singleton
31 | fun provideHttpClient(): OkHttpClient = OkHttpClient.Builder()
32 | .connectTimeout(TIMEOUT, TimeUnit.MILLISECONDS)
33 | .readTimeout(TIMEOUT, TimeUnit.MILLISECONDS)
34 | .callTimeout(TIMEOUT, TimeUnit.MILLISECONDS)
35 | .addInterceptor(UserAgentInterceptor())
36 | //.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.HEADERS })
37 | .cookieJar(CookieJarHelper())
38 | .addInterceptor(Retry())
39 | .dns(SmartDns)
40 | .build()
41 |
42 | @Provides
43 | @Singleton
44 | fun provideIwaraParser(okHttpClient: OkHttpClient) = IwaraParser(okHttpClient)
45 |
46 | @Provides
47 | @Singleton
48 | fun provideIwaraService(okHttpClient: OkHttpClient): IwaraService = Retrofit.Builder()
49 | .client(okHttpClient)
50 | .baseUrl("https://ecchi.iwara.tv/")
51 | .addConverterFactory(GsonConverterFactory.create())
52 | .build()
53 | .create(IwaraService::class.java)
54 |
55 | @Provides
56 | @Singleton
57 | fun provideIwaraApi(
58 | iwaraParser: IwaraParser,
59 | iwaraService: IwaraService
60 | ): IwaraApi =
61 | IwaraApiImpl(iwaraParser, iwaraService)
62 |
63 | @Provides
64 | @Singleton
65 | fun provideBackendApi(okHttpClient: OkHttpClient): Iwara4aBackendAPI = Retrofit.Builder()
66 | .client(okHttpClient)
67 | .baseUrl("https://iwara.matrix.rip")
68 | .addConverterFactory(GsonConverterFactory.create())
69 | .build()
70 | .create(Iwara4aBackendAPI::class.java)
71 |
72 | @Provides
73 | @Singleton
74 | fun provideOrenoApi(): Oreno3dApi = Oreno3dApi()
75 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/module/StorageModule.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.module
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import com.rerere.iwara4a.data.dao.AppDatabase
6 | import com.rerere.iwara4a.data.model.session.SessionManager
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.qualifiers.ApplicationContext
11 | import dagger.hilt.components.SingletonComponent
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | object StorageModule {
17 | @Provides
18 | @Singleton
19 | fun provideSessionManager(@ApplicationContext context: Context) = SessionManager(context)
20 |
21 | @Provides
22 | @Singleton
23 | fun provideDatabase(@ApplicationContext context: Context) = Room.databaseBuilder(
24 | context, AppDatabase::class.java, "iwaradb"
25 | ).build()
26 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/data/repo/UserRepo.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.data.repo
2 |
3 | import androidx.annotation.IntRange
4 | import com.rerere.iwara4a.data.api.IwaraApi
5 | import com.rerere.iwara4a.data.api.Response
6 | import com.rerere.iwara4a.data.model.comment.CommentList
7 | import com.rerere.iwara4a.data.model.friends.FriendList
8 | import com.rerere.iwara4a.data.model.session.Session
9 | import com.rerere.iwara4a.data.model.user.Self
10 | import com.rerere.iwara4a.data.model.user.UserData
11 | import com.rerere.iwara4a.util.autoRetry
12 | import javax.inject.Inject
13 | import javax.inject.Singleton
14 |
15 | @Singleton
16 | class UserRepo @Inject constructor(
17 | private val iwaraApi: IwaraApi
18 | ) {
19 | suspend fun login(username: String, password: String): Response =
20 | iwaraApi.login(username, password)
21 |
22 | suspend fun getSelf(session: Session): Response = autoRetry(2) { iwaraApi.getSelf(session) }
23 |
24 | suspend fun getUser(session: Session, userId: String): Response =
25 | iwaraApi.getUser(session, userId)
26 |
27 | suspend fun getUserPageComment(
28 | session: Session,
29 | userId: String,
30 | @IntRange(from = 0) page: Int
31 | ): Response = iwaraApi.getUserPageComment(session, userId, page)
32 |
33 | suspend fun getFriendList(session: Session): Response =
34 | iwaraApi.getFriendList(session)
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/service/DownloadEntry.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.service
2 |
3 | /**
4 | * 代表一个下载任务
5 | *
6 | * @param url 下载地址
7 | * @param nid 视频NID
8 | * @param title 视频标题
9 | * @param preview 预览封面
10 | */
11 | data class DownloadEntry(
12 | val url: String,
13 | val nid: Int,
14 | val title: String,
15 | val preview: String
16 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/activity/CrashActivity.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.activity
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.foundation.layout.PaddingValues
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.lazy.LazyColumn
9 | import androidx.compose.foundation.lazy.items
10 | import androidx.compose.material.icons.Icons
11 | import androidx.compose.material.icons.outlined.Close
12 | import androidx.compose.material3.*
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.input.nestedscroll.nestedScroll
15 | import androidx.compose.ui.unit.dp
16 | import com.rerere.iwara4a.ui.util.plus
17 |
18 | class CrashActivity : ComponentActivity() {
19 | override fun onCreate(savedInstanceState: Bundle?) {
20 | super.onCreate(savedInstanceState)
21 |
22 | val stackTrace = intent.getStringExtra("stackTrace") ?: ""
23 |
24 | setContent {
25 | MaterialTheme {
26 | val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
27 | Scaffold(
28 | modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
29 | topBar = {
30 | LargeTopAppBar(
31 | title = {
32 | Text("APP Crashed")
33 | },
34 | navigationIcon = {
35 | IconButton(
36 | onClick = {
37 | finish()
38 | }
39 | ) {
40 | Icon(Icons.Outlined.Close, null)
41 | }
42 | },
43 | scrollBehavior = scrollBehavior
44 | )
45 | }
46 | ) { innerPadding ->
47 | LazyColumn(
48 | modifier = Modifier.fillMaxSize(),
49 | contentPadding = innerPadding + PaddingValues(8.dp),
50 | ) {
51 | items(stackTrace.split("\n")) {
52 | Text(
53 | text = it,
54 | style = MaterialTheme.typography.bodySmall
55 | )
56 | }
57 | }
58 | }
59 | }
60 | }
61 | }
62 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/activity/RouterViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.activity
2 |
3 | import android.app.Application
4 | import android.util.Log
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.mutableStateOf
7 | import androidx.compose.runtime.setValue
8 | import androidx.lifecycle.AndroidViewModel
9 | import androidx.lifecycle.ViewModel
10 | import androidx.lifecycle.viewModelScope
11 | import com.rerere.iwara4a.AppContext
12 | import com.rerere.iwara4a.data.api.backend.Iwara4aBackendAPI
13 | import com.rerere.iwara4a.data.model.session.SessionManager
14 | import com.rerere.iwara4a.data.model.user.Self
15 | import com.rerere.iwara4a.data.repo.UserRepo
16 | import dagger.hilt.android.lifecycle.HiltViewModel
17 | import dagger.hilt.android.qualifiers.ApplicationContext
18 | import kotlinx.coroutines.launch
19 | import javax.inject.Inject
20 |
21 | private const val TAG = "RouterViewModel"
22 |
23 | @HiltViewModel
24 | class RouterViewModel @Inject constructor(
25 | private val sessionManager: SessionManager,
26 | private val userRepo: UserRepo,
27 | private val backendAPI: Iwara4aBackendAPI,
28 | application: Application
29 | ) : AndroidViewModel(application) {
30 |
31 | init {
32 | viewModelScope.launch {
33 | kotlin.runCatching {
34 | val deviceUUID = (application as AppContext).deviceUUID.toString()
35 | Log.i(TAG, "stats: posting usage stats to backend: $deviceUUID")
36 | backendAPI.postStatusData(
37 | uuid = deviceUUID
38 | )
39 | }.onFailure {
40 | it.printStackTrace()
41 | }
42 | }
43 | }
44 |
45 | // 用户数据
46 | var userData by mutableStateOf(Self.GUEST)
47 | var userDataFetched by mutableStateOf(false)
48 |
49 | private fun isLogin() = sessionManager.session.key.isNotEmpty()
50 |
51 | suspend fun prepareUserData() {
52 | if (isLogin()) {
53 | val response = userRepo.getSelf(sessionManager.session)
54 | userData = if (response.isSuccess()) {
55 | response.read()
56 | } else {
57 | if(response.errorMessage() == java.lang.IllegalStateException::class.java.name) {
58 | Self.GUEST // 登录过期
59 | } else {
60 | // 没有网络连接?
61 | Self.GUEST.copy(
62 | nickname = "???"
63 | )
64 | }
65 | }
66 | }
67 | userDataFetched = true
68 | }
69 |
70 | init {
71 | viewModelScope.launch {
72 | prepareUserData()
73 | }
74 | }
75 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/Chip.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.foundation.shape.RoundedCornerShape
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.outlined.Check
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.ProvideTextStyle
12 | import androidx.compose.material3.Surface
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.unit.dp
17 |
18 | @Composable
19 | fun FilterChip(
20 | selected: Boolean,
21 | onClick: () -> Unit,
22 | label: @Composable () -> Unit
23 | ) {
24 | Surface(
25 | selected = selected,
26 | onClick = onClick,
27 | shape = RoundedCornerShape(8.dp),
28 | tonalElevation = if(selected) 8.dp else 0.dp,
29 | shadowElevation = 1.dp,
30 | color = if(selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface
31 | ){
32 | Row(
33 | modifier = Modifier.padding(4.dp),
34 | verticalAlignment = Alignment.CenterVertically
35 | ) {
36 | AnimatedVisibility(selected) {
37 | Icon(
38 | imageVector = Icons.Outlined.Check,
39 | contentDescription = null
40 | )
41 | }
42 | ProvideTextStyle(MaterialTheme.typography.labelLarge) {
43 | label()
44 | }
45 | }
46 | }
47 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/ComposeWebview.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Intent
5 | import android.content.pm.PackageManager
6 | import android.net.Uri
7 | import android.webkit.*
8 | import androidx.activity.compose.BackHandler
9 | import androidx.compose.animation.AnimatedVisibility
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.fillMaxSize
12 | import androidx.compose.foundation.layout.fillMaxWidth
13 | import androidx.compose.material3.LinearProgressIndicator
14 | import androidx.compose.runtime.*
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.platform.LocalContext
18 | import androidx.compose.ui.viewinterop.AndroidView
19 | import com.rerere.iwara4a.data.model.session.Session
20 | import com.rerere.iwara4a.ui.local.LocalNavController
21 |
22 | @SuppressLint("SetJavaScriptEnabled")
23 | @Composable
24 | fun ComposeWebview(
25 | modifier: Modifier = Modifier,
26 | link: String,
27 | session: Session?,
28 | onTitleChange: (String) -> Unit
29 | ) {
30 | val context = LocalContext.current
31 | val navController = LocalNavController.current
32 | var progress by remember {
33 | mutableStateOf(0)
34 | }
35 | val webView = remember {
36 | WebView(context).apply {
37 | webChromeClient = object : WebChromeClient() {
38 | override fun onProgressChanged(view: WebView, newProgress: Int) {
39 | progress = newProgress
40 | }
41 |
42 | override fun onReceivedTitle(view: WebView, title0: String) {
43 | onTitleChange(title0)
44 | }
45 | }
46 | webViewClient = object : WebViewClient() {
47 | override fun shouldOverrideUrlLoading(
48 | view: WebView?,
49 | request: WebResourceRequest?
50 | ): Boolean {
51 | request?.url?.let {
52 | if (it.host == "ecchi.iwara.tv") {
53 | val path = it.path ?: ""
54 | if(path.startsWith("/videos/")) {
55 | val intent = Intent(Intent.ACTION_VIEW).apply {
56 | data = Uri.parse("iwara4a://video/${path.substringAfter("/videos/")}")
57 | }
58 | if(context.packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) {
59 | context.startActivity(intent)
60 | return true
61 | }
62 | }
63 | }
64 | }
65 | return super.shouldOverrideUrlLoading(view, request)
66 | }
67 | }
68 | settings.javaScriptEnabled = true
69 | session?.let {
70 | CookieManager.getInstance().let { manager ->
71 | manager.acceptCookie()
72 | manager.acceptThirdPartyCookies(this)
73 | manager.setCookie(
74 | ".iwara.tv",
75 | it.toString()
76 | )
77 | }
78 | }
79 |
80 | loadUrl(link)
81 | }
82 | }
83 |
84 | BackHandler {
85 | if (webView.canGoBack()) {
86 | webView.goBack()
87 | } else {
88 | navController.popBackStack()
89 | }
90 | }
91 |
92 | Box(modifier = modifier) {
93 | AndroidView(
94 | modifier = Modifier.fillMaxSize(),
95 | factory = {
96 | webView
97 | }
98 | )
99 |
100 | AnimatedVisibility(
101 | modifier = Modifier.align(Alignment.TopCenter),
102 | visible = progress < 100
103 | ) {
104 | LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), progress = progress / 100f)
105 | }
106 | }
107 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/IwaraTopBar.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import androidx.compose.foundation.layout.RowScope
4 | import androidx.compose.material.icons.Icons
5 | import androidx.compose.material.icons.outlined.ArrowBack
6 | import androidx.compose.material3.*
7 | import androidx.compose.runtime.Composable
8 | import com.rerere.iwara4a.ui.local.LocalNavController
9 |
10 | @Composable
11 | fun Md3TopBar(
12 | title: @Composable () -> Unit,
13 | navigationIcon: @Composable () -> Unit = {},
14 | actions: @Composable RowScope.() -> Unit = {},
15 | appBarStyle: AppBarStyle = AppBarStyle.Small,
16 | scrollBehavior: TopAppBarScrollBehavior? = null
17 | ) {
18 | val colors = when (appBarStyle) {
19 | AppBarStyle.Small -> TopAppBarDefaults.smallTopAppBarColors()
20 | AppBarStyle.Medium -> TopAppBarDefaults.mediumTopAppBarColors()
21 | AppBarStyle.Large -> TopAppBarDefaults.largeTopAppBarColors()
22 | AppBarStyle.CenterAligned -> TopAppBarDefaults.centerAlignedTopAppBarColors()
23 | }
24 | when (appBarStyle) {
25 | AppBarStyle.Small -> {
26 | TopAppBar(
27 | title = title,
28 | navigationIcon = navigationIcon,
29 | actions = actions,
30 | colors = colors,
31 | scrollBehavior = scrollBehavior
32 | )
33 | }
34 | AppBarStyle.Medium -> {
35 | MediumTopAppBar(
36 | title = title,
37 | navigationIcon = navigationIcon,
38 | actions = actions,
39 | colors = colors,
40 | scrollBehavior = scrollBehavior
41 | )
42 | }
43 | AppBarStyle.Large -> {
44 | LargeTopAppBar(
45 | title = title,
46 | navigationIcon = navigationIcon,
47 | actions = actions,
48 | colors = colors,
49 | scrollBehavior = scrollBehavior
50 | )
51 | }
52 | AppBarStyle.CenterAligned -> {
53 | CenterAlignedTopAppBar(
54 | title = title,
55 | navigationIcon = navigationIcon,
56 | actions = actions,
57 | colors = colors,
58 | scrollBehavior = scrollBehavior
59 | )
60 | }
61 | }
62 | }
63 |
64 | @Composable
65 | fun BackIcon() {
66 | val navController = LocalNavController.current
67 | IconButton(
68 | onClick = {
69 | navController.popBackStack()
70 | }
71 | ) {
72 | Icon(Icons.Outlined.ArrowBack, null)
73 | }
74 | }
75 |
76 |
77 | @Composable
78 | fun SimpleIwaraTopBar(
79 | title: String
80 | ) {
81 | Md3TopBar(
82 | appBarStyle = AppBarStyle.Small,
83 | title = {
84 | Text(text = title)
85 | },
86 | navigationIcon = {
87 | BackIcon()
88 | }
89 | )
90 | }
91 |
92 | /**
93 | * 顶栏样式
94 | */
95 | enum class AppBarStyle {
96 | Small,
97 | CenterAligned,
98 | Medium,
99 | Large
100 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/MaterialDialogState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import androidx.compose.runtime.*
4 |
5 | @Stable
6 | class MaterialDialogState {
7 | private var show by mutableStateOf(false)
8 |
9 | fun show() {
10 | show = true
11 | }
12 |
13 | fun hide() {
14 | show = false
15 | }
16 |
17 | fun isVisible() = show
18 | }
19 |
20 | @Composable
21 | fun rememberMaterialDialogState() = remember {
22 | MaterialDialogState()
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/PagerIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.layout.offset
5 | import androidx.compose.foundation.layout.width
6 | import androidx.compose.foundation.layout.wrapContentSize
7 | import androidx.compose.material3.TabPosition
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.composed
11 | import androidx.compose.ui.unit.Dp
12 | import androidx.compose.ui.unit.dp
13 | import androidx.compose.ui.unit.lerp
14 | import com.google.accompanist.pager.PagerState
15 | import kotlin.math.absoluteValue
16 |
17 | fun Modifier.pagerTabIndicatorOffset(
18 | pagerState: PagerState,
19 | tabPositions: List,
20 | ): Modifier = composed {
21 | // If there are no pages, nothing to show
22 | if (pagerState.pageCount == 0) return@composed this
23 |
24 | val targetIndicatorOffset: Dp
25 | val indicatorWidth: Dp
26 |
27 | val currentTab = tabPositions[minOf(tabPositions.lastIndex, pagerState.currentPage)]
28 | val targetPage = pagerState.targetPage
29 | val targetTab = tabPositions.getOrNull(targetPage)
30 |
31 | if (targetTab != null) {
32 | // The distance between the target and current page. If the pager is animating over many
33 | // items this could be > 1
34 | val targetDistance = (targetPage - pagerState.currentPage).absoluteValue
35 | // Our normalized fraction over the target distance
36 | val fraction = (pagerState.currentPageOffset / kotlin.math.max(targetDistance, 1)).absoluteValue
37 |
38 | targetIndicatorOffset = lerp(currentTab.left, targetTab.left, fraction)
39 | indicatorWidth = lerp(currentTab.width, targetTab.width, fraction).absoluteValue
40 | } else {
41 | // Otherwise we just use the current tab/page
42 | targetIndicatorOffset = currentTab.left
43 | indicatorWidth = currentTab.width
44 | }
45 |
46 | fillMaxWidth()
47 | .wrapContentSize(Alignment.BottomStart)
48 | .offset(x = targetIndicatorOffset)
49 | .width(indicatorWidth)
50 | }
51 |
52 | private inline val Dp.absoluteValue: Dp
53 | get() = value.absoluteValue.dp
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/PagingListIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.lazy.grid.GridItemSpan
6 | import androidx.compose.foundation.lazy.grid.LazyGridScope
7 | import androidx.compose.foundation.shape.CircleShape
8 | import androidx.compose.material3.CircularProgressIndicator
9 | import androidx.compose.material3.Text
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.clip
13 | import androidx.compose.ui.res.painterResource
14 | import androidx.compose.ui.unit.dp
15 | import androidx.paging.LoadState
16 | import androidx.paging.compose.LazyPagingItems
17 | import com.rerere.iwara4a.R
18 | import com.rerere.iwara4a.ui.component.modifier.noRippleClickable
19 |
20 | /**
21 | * 为LazyGrid+Paging提供底部指示器
22 | *
23 | * @param pagingItems 分页数据
24 | */
25 | fun LazyGridScope.appendIndicator(pagingItems: LazyPagingItems) {
26 | when (pagingItems.loadState.append) {
27 | LoadState.Loading -> {
28 | item(
29 | span = {
30 | GridItemSpan(2)
31 | }
32 | ) {
33 | Row(
34 | modifier = Modifier
35 | .fillMaxSize()
36 | .padding(8.dp),
37 | horizontalArrangement = Arrangement.Center,
38 | verticalAlignment = Alignment.CenterVertically
39 | ) {
40 | CircularProgressIndicator(Modifier.size(30.dp))
41 | Text(
42 | modifier = Modifier.padding(horizontal = 16.dp),
43 | text = "加载中..."
44 | )
45 | }
46 | }
47 | }
48 | is LoadState.Error -> {
49 | item(
50 | span = {
51 | GridItemSpan(2)
52 | }
53 | ) {
54 | Row(
55 | modifier = Modifier
56 | .fillMaxSize()
57 | .noRippleClickable { pagingItems.retry() }
58 | .padding(8.dp),
59 | horizontalArrangement = Arrangement.Center,
60 | verticalAlignment = Alignment.CenterVertically
61 | ) {
62 | Column(horizontalAlignment = Alignment.CenterHorizontally) {
63 | Box(
64 | modifier = Modifier
65 | .size(140.dp)
66 | .padding(10.dp)
67 | .clip(CircleShape)
68 | ) {
69 | Image(
70 | modifier = Modifier.fillMaxSize(),
71 | painter = painterResource(R.drawable.anime_2),
72 | contentDescription = null
73 | )
74 | }
75 | Text(
76 | modifier = Modifier.padding(horizontal = 16.dp),
77 | text = "加载失败: ${(pagingItems.loadState.append as LoadState.Error).error.message}"
78 | )
79 | Text(text = "点击重试")
80 | }
81 | }
82 | }
83 | }
84 | else -> { /* do nothing */ }
85 | }
86 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/RadomLoadingAnim.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import androidx.compose.foundation.layout.fillMaxSize
4 | import androidx.compose.foundation.layout.size
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.unit.dp
9 | import com.airbnb.lottie.compose.LottieAnimation
10 | import com.airbnb.lottie.compose.LottieCompositionSpec
11 | import com.airbnb.lottie.compose.LottieConstants
12 | import com.airbnb.lottie.compose.rememberLottieComposition
13 | import com.rerere.iwara4a.R
14 | import com.rerere.iwara4a.ui.component.basic.Centered
15 |
16 | private val animList = listOf(
17 | R.raw.niko,
18 | R.raw.loading_anim_2
19 | )
20 |
21 | @Composable
22 | fun RandomLoadingAnim() {
23 | Centered(
24 | modifier = Modifier.fillMaxSize()
25 | ) {
26 | val composition by rememberLottieComposition(
27 | LottieCompositionSpec.RawRes(
28 | animList.random()
29 | )
30 | )
31 | LottieAnimation(
32 | modifier = Modifier.size(200.dp),
33 | composition = composition,
34 | iterations = LottieConstants.IterateForever
35 | )
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/ReplyDialog.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import androidx.compose.runtime.*
4 | import com.rerere.iwara4a.data.model.comment.CommentPostParam
5 |
6 | @Composable
7 | fun rememberReplyDialogState() = remember {
8 | ReplyDialogState()
9 | }
10 |
11 | @Stable
12 | class ReplyDialogState {
13 | var replyTo by mutableStateOf("")
14 | var nid by mutableStateOf(0)
15 | var commentId by mutableStateOf(-1)
16 | var commentPostParam by mutableStateOf(CommentPostParam.Default)
17 |
18 | var content: String by mutableStateOf("")
19 |
20 | var posting by mutableStateOf(false)
21 |
22 | var showDialog by mutableStateOf(false)
23 |
24 | fun open(
25 | replyTo: String,
26 | nid: Int,
27 | commentId: Int?,
28 | commentPostParam: CommentPostParam
29 | ) {
30 | this.replyTo = replyTo
31 | this.nid = nid
32 | this.commentId = commentId ?: -1
33 | this.commentPostParam = commentPostParam
34 |
35 | this.showDialog = true
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/SmartLinkText.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.text.ClickableText
5 | import androidx.compose.material3.LocalContentColor
6 | import androidx.compose.material3.LocalTextStyle
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.graphics.Color
10 | import androidx.compose.ui.platform.LocalContext
11 | import androidx.compose.ui.text.SpanStyle
12 | import androidx.compose.ui.text.TextStyle
13 | import androidx.compose.ui.text.buildAnnotatedString
14 | import androidx.compose.ui.text.withStyle
15 | import androidx.compose.ui.util.fastForEach
16 | import com.rerere.iwara4a.util.openUrl
17 |
18 | private const val TAG = "SmartLinkText"
19 |
20 | @Composable
21 | fun SmartLinkText(
22 | modifier: Modifier = Modifier,
23 | text: String,
24 | maxLines: Int = Int.MAX_VALUE,
25 | style: TextStyle = LocalTextStyle.current
26 | ) {
27 | val elements = text.parseUrls()
28 | val context = LocalContext.current
29 | ClickableText(
30 | modifier = modifier,
31 | text = buildAnnotatedString {
32 | elements.fastForEach {
33 | if (it.isUrl) {
34 | withStyle(style = style.toSpanStyle().merge(SpanStyle(Color.Blue))) {
35 | append(it.text)
36 | }
37 | } else {
38 | withStyle(style = style.toSpanStyle().merge(SpanStyle(LocalContentColor.current))) {
39 | append(it.text)
40 | }
41 | }
42 | }
43 | },
44 | maxLines = maxLines,
45 | style = style
46 | ) {
47 | var cursor = 0
48 | for (item in elements) {
49 | if (it >= cursor && it < cursor + item.text.length) {
50 | if (item.isUrl) {
51 | Log.i(TAG, "SmartLinkText: Clicked Url: ${item.text}")
52 | context.openUrl(item.text)
53 | }
54 | break
55 | }
56 | cursor += item.text.length
57 | }
58 | }
59 | }
60 |
61 | private val REGEX =
62 | Regex("(https?:\\/\\/(?:www\\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\\.[^\\s]{2,}|www\\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\\.[^\\s]{2,}|https?:\\/\\/(?:www\\.|(?!www))[a-zA-Z0-9]+\\.[^\\s]{2,}|www\\.[a-zA-Z0-9]+\\.[^\\s]{2,})")
63 | // Regex("http[s]?://(?:(?!http[s]?://)[a-zA-Z]|[0-9]|[\$\\-_@.&+/]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+")
64 |
65 | fun String.parseUrls(): List {
66 | val allLinks = REGEX.findAll(this).map { it.value }.toList()
67 | val elements = arrayListOf()
68 | var str = this
69 | allLinks.fastForEach {
70 | elements += TextElement(str.substring(0, str.indexOf(it)), false)
71 | elements += TextElement(str.substring(str.indexOf(it), str.indexOf(it) + it.length), true)
72 | str = str.substring(str.indexOf(it) + it.length)
73 | }
74 | elements += TextElement(str, false)
75 | return elements
76 | }
77 |
78 | data class TextElement(
79 | val text: String,
80 | val isUrl: Boolean
81 | ) {
82 | fun isImage() = isUrl && text.let {
83 | it.endsWith(".png") || it.endsWith(".jpg")
84 | }
85 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/basic/Centered.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.basic
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.BoxScope
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Alignment
7 | import androidx.compose.ui.Modifier
8 |
9 | @Composable
10 | fun Centered(
11 | modifier: Modifier = Modifier,
12 | propagateMinConstraints: Boolean = false,
13 | content: @Composable BoxScope.() -> Unit
14 | ) {
15 | Box(
16 | modifier = modifier,
17 | propagateMinConstraints = propagateMinConstraints,
18 | contentAlignment = Alignment.Center,
19 | content = content
20 | )
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/basic/LazyStaggeredGrid.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.basic
2 |
3 | import androidx.compose.foundation.LocalOverscrollConfiguration
4 | import androidx.compose.foundation.gestures.scrollBy
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.lazy.LazyColumn
8 | import androidx.compose.foundation.lazy.LazyListState
9 | import androidx.compose.foundation.lazy.rememberLazyListState
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.CompositionLocalProvider
12 | import androidx.compose.runtime.rememberCoroutineScope
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.geometry.Offset
15 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
16 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource
17 | import androidx.compose.ui.input.nestedscroll.nestedScroll
18 | import androidx.compose.ui.unit.dp
19 | import kotlinx.coroutines.Dispatchers
20 | import kotlinx.coroutines.launch
21 |
22 | /**
23 | * 一个基于多重LazyColumn的StaggeredGrid实现
24 | *
25 | * [Github](https://github.com/savvasdalkitsis/lazy-staggered-grid)
26 | */
27 | @Composable
28 | fun LazyStaggeredGrid(
29 | columnCount: Int,
30 | states: List = List(columnCount) { rememberLazyListState() },
31 | contentPadding: PaddingValues = PaddingValues(0.dp),
32 | content: LazyStaggeredGridScope.() -> Unit,
33 | ) {
34 | check(columnCount == states.size) {
35 | "Invalid number of lazy list states. Expected: $columnCount. Actual: ${states.size}"
36 | }
37 |
38 | val scope = rememberCoroutineScope { Dispatchers.Main.immediate }
39 | val scrollConnections = List(columnCount) { index ->
40 | object : NestedScrollConnection {
41 | override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
42 | val delta = available.y
43 | scope.launch {
44 | states.forEachIndexed { stateIndex, state ->
45 | if (stateIndex != index) {
46 | state.scrollBy(-delta)
47 | }
48 | }
49 | }
50 | return Offset.Zero
51 | }
52 | }
53 | }
54 | val gridScope = RealLazyStaggeredGridScope(columnCount).apply(content)
55 |
56 | // Disable overscroll otherwise it'll only overscroll one column.
57 | CompositionLocalProvider(LocalOverscrollConfiguration provides null) {
58 | Row {
59 | for (index in 0 until columnCount) {
60 | LazyColumn(
61 | contentPadding = contentPadding,
62 | state = states[index],
63 | modifier = Modifier
64 | .nestedScroll(scrollConnections[index])
65 | .weight(1f)
66 | ) {
67 | for ((key, itemContent) in gridScope.items[index]) {
68 | item(key) { itemContent() }
69 | }
70 | }
71 | }
72 | }
73 | }
74 | }
75 |
76 | /** Receiver scope which is used by [LazyStaggeredGrid]. */
77 | interface LazyStaggeredGridScope {
78 |
79 | /** Adds a single item. */
80 | fun item(
81 | key: Any? = null,
82 | content: @Composable () -> Unit
83 | )
84 |
85 | /** Adds a [count] of items. */
86 | fun items(
87 | count: Int,
88 | key: ((index: Int) -> Any)? = null,
89 | itemContent: @Composable (index: Int) -> Unit
90 | )
91 | }
92 |
93 | /** Adds a list of items. */
94 | inline fun LazyStaggeredGridScope.items(
95 | items: List,
96 | noinline key: ((item: T) -> Any)? = null,
97 | crossinline itemContent: @Composable (item: T) -> Unit
98 | ) = items(
99 | count = items.size,
100 | key = if (key != null) { index -> key(items[index]) } else null,
101 | itemContent = { itemContent(items[it]) }
102 | )
103 |
104 | private class RealLazyStaggeredGridScope(private val columnCount: Int) : LazyStaggeredGridScope {
105 | val items = Array(columnCount) { mutableListOf Unit>>() }
106 | var currentIndex = 0
107 |
108 | override fun item(key: Any?, content: @Composable () -> Unit) {
109 | items[currentIndex % columnCount] += key to content
110 | currentIndex += 1
111 | }
112 |
113 | override fun items(count: Int, key: ((Int) -> Any)?, itemContent: @Composable (Int) -> Unit) {
114 | for (index in 0 until count) {
115 | item(key?.invoke(index)) { itemContent(index) }
116 | }
117 | }
118 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/danmu/Danmaku.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.danmu
2 |
3 | import androidx.compose.material3.Text
4 | import androidx.compose.runtime.Composable
5 |
6 | sealed class Danmaku(
7 | val position: Long
8 | ) {
9 | abstract fun display(): @Composable () -> Unit
10 | }
11 |
12 | class BasicDanmaku(
13 | val text: String,
14 | position: Long
15 | ) : Danmaku(position) {
16 | override fun display(): @Composable () -> Unit = {
17 | Text(text)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/danmu/DanmakuBox.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.danmu
2 |
3 | import androidx.compose.foundation.layout.BoxWithConstraints
4 | import androidx.compose.foundation.layout.wrapContentSize
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.Stable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 |
10 | @Stable
11 | class DanmakuState {
12 | private val danmakuList = arrayListOf()
13 |
14 | fun addDanmaku(danmaku: Danmaku) {
15 | danmakuList += danmaku
16 | }
17 | }
18 |
19 | @Composable
20 | fun DanmakuBox(
21 | modifier: Modifier = Modifier,
22 | state: DanmakuState,
23 | content: @Composable () -> Unit
24 | ) {
25 | BoxWithConstraints(
26 | modifier = modifier
27 | .wrapContentSize(),
28 | contentAlignment = Alignment.TopStart
29 | ) {
30 | content()
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/md/Banner.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.md
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material3.Card
5 | import androidx.compose.material3.LocalTextStyle
6 | import androidx.compose.material3.MaterialTheme
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.text.font.FontWeight
12 | import androidx.compose.ui.unit.dp
13 |
14 | /**
15 | * 一个简单的Material Banner组件实现
16 | *
17 | * @param modifier Modifier
18 | * @param icon Banner图标
19 | * @param text Banner消息内容
20 | * @param buttons Banner下方的TextButton
21 | */
22 | @Composable
23 | fun Banner(
24 | modifier: Modifier = Modifier,
25 | icon: @Composable (() -> Unit)? = null,
26 | title: @Composable () -> Unit = {},
27 | text: @Composable () -> Unit,
28 | buttons: @Composable RowScope.() -> Unit
29 | ) {
30 | Card(
31 | modifier = modifier
32 | ) {
33 | Column {
34 | // 第一行
35 | Row(
36 | modifier = Modifier.padding(16.dp),
37 | horizontalArrangement = Arrangement.spacedBy(16.dp),
38 | verticalAlignment = Alignment.CenterVertically
39 | ) {
40 | // Icon
41 | if (icon != null) {
42 | Box(
43 | modifier = Modifier.size(40.dp),
44 | contentAlignment = Alignment.Center
45 | ) {
46 | icon()
47 | }
48 | }
49 | // Text
50 | Column(
51 | modifier = Modifier.weight(1f),
52 | verticalArrangement = Arrangement.spacedBy(8.dp)
53 | ) {
54 | CompositionLocalProvider(
55 | LocalTextStyle provides MaterialTheme.typography.titleMedium.copy(
56 | fontWeight = FontWeight.Bold
57 | )
58 | ) {
59 | title()
60 | }
61 | CompositionLocalProvider(
62 | LocalTextStyle provides MaterialTheme.typography.bodyMedium
63 | ) {
64 | text()
65 | }
66 | }
67 | }
68 | // 第二行
69 | Row(
70 | modifier = Modifier.fillMaxWidth(),
71 | horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
72 | ) {
73 | buttons()
74 | }
75 | }
76 | }
77 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/md/ButtonX.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.md
2 |
3 | import androidx.compose.animation.Crossfade
4 | import androidx.compose.foundation.layout.RowScope
5 | import androidx.compose.material3.*
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 |
9 | @Composable
10 | fun ButtonX(
11 | style: ButtonStyle,
12 | onClick: () -> Unit,
13 | modifier: Modifier = Modifier,
14 | enabled: Boolean = true,
15 | content: @Composable RowScope.() -> Unit
16 | ) {
17 | Crossfade(style) { buttonStyle ->
18 | when (buttonStyle) {
19 | ButtonStyle.Filled -> Button(
20 | onClick = onClick,
21 | modifier = modifier,
22 | enabled = enabled,
23 | content = content
24 | )
25 | ButtonStyle.Outlined -> OutlinedButton(
26 | onClick = onClick,
27 | modifier = modifier,
28 | enabled = enabled,
29 | content = content
30 | )
31 | ButtonStyle.Text -> TextButton(
32 | onClick = onClick,
33 | modifier = modifier,
34 | enabled = enabled,
35 | content = content
36 | )
37 | ButtonStyle.FilledTonal -> FilledTonalButton(
38 | onClick = onClick,
39 | modifier = modifier,
40 | enabled = enabled,
41 | content = content
42 | )
43 | ButtonStyle.Elevated -> ElevatedButton(
44 | onClick = onClick,
45 | modifier = modifier,
46 | enabled = enabled,
47 | content = content
48 | )
49 | }
50 | }
51 | }
52 |
53 | enum class ButtonStyle {
54 | Filled,
55 | Outlined,
56 | Text,
57 | FilledTonal,
58 | Elevated
59 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/modifier/ModifierEx.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.modifier
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.composed
9 | import androidx.compose.ui.draw.blur
10 | import androidx.compose.ui.unit.dp
11 | import me.rerere.compose_setting.preference.rememberBooleanPreference
12 |
13 | fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed {
14 | clickable(
15 | indication = null,
16 | interactionSource = remember { MutableInteractionSource() }
17 | ) {
18 | onClick()
19 | }
20 | }
21 |
22 | fun Modifier.nsfw() = composed {
23 | val demoMode by rememberBooleanPreference(
24 | key = "demoMode",
25 | default = false
26 | )
27 | if(demoMode) this.blur(5.dp) else Modifier
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/paging3/PagingGrid.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.paging3
2 |
3 | import androidx.compose.foundation.lazy.grid.LazyGridScope
4 | import androidx.compose.runtime.Composable
5 | import androidx.paging.compose.LazyPagingItems
6 |
7 | fun LazyGridScope.items(
8 | items: LazyPagingItems,
9 | content: @Composable (T?) -> Unit
10 | ) {
11 | items(
12 | count = items.itemCount
13 | ) { index ->
14 | content(items[index])
15 | }
16 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/component/player/VideoPlayer.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.component.player
2 |
3 | import android.view.ViewGroup
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.material3.LocalContentColor
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.CompositionLocalProvider
9 | import androidx.compose.runtime.DisposableEffect
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.platform.LocalContext
13 | import androidx.compose.ui.semantics.contentDescription
14 | import androidx.compose.ui.semantics.semantics
15 | import androidx.compose.ui.viewinterop.AndroidView
16 | import androidx.lifecycle.Lifecycle
17 | import com.google.android.exoplayer2.ui.StyledPlayerView
18 | import com.rerere.iwara4a.ui.component.basic.Centered
19 | import com.rerere.iwara4a.ui.states.OnLifecycleEvent
20 | import java.lang.ref.WeakReference
21 |
22 | @Composable
23 | private fun VideoPlayerSurface(
24 | state: PlayerState
25 | ) {
26 | val context = LocalContext.current
27 | AndroidView(
28 | factory = {
29 | StyledPlayerView(context).apply {
30 | layoutParams = ViewGroup.LayoutParams(
31 | ViewGroup.LayoutParams.MATCH_PARENT,
32 | ViewGroup.LayoutParams.MATCH_PARENT
33 | )
34 | }.also {
35 | state.surfaceView = WeakReference(it)
36 | it.player = state.player
37 | it.useController = false
38 | }
39 | },
40 | modifier = Modifier
41 | .fillMaxSize()
42 | .semantics {
43 | contentDescription = "Video Player"
44 | }
45 | )
46 | DisposableEffect(state) {
47 | onDispose {
48 | state.player.release()
49 | }
50 | }
51 | }
52 |
53 | @Composable
54 | fun VideoPlayer(
55 | modifier: Modifier = Modifier,
56 | state: PlayerState,
57 | controller: @Composable () -> Unit = {}
58 | ) {
59 | CompositionLocalProvider(
60 | LocalContentColor provides Color.White
61 | ) {
62 | Centered(
63 | modifier = Modifier
64 | .background(Color.Black)
65 | .then(modifier)
66 | ) {
67 | VideoPlayerSurface(
68 | state = state
69 | )
70 | controller()
71 | }
72 |
73 | OnLifecycleEvent { _, event ->
74 | when (event) {
75 | Lifecycle.Event.ON_STOP -> {
76 | state.player.pause()
77 | }
78 | Lifecycle.Event.ON_START -> {
79 | state.player.play()
80 | }
81 | else -> {}
82 | }
83 | }
84 | }
85 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/local/LocalValue.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.local
2 |
3 | import androidx.compose.material3.windowsizeclass.WindowSizeClass
4 | import androidx.compose.runtime.compositionLocalOf
5 | import androidx.navigation.NavController
6 | import com.rerere.iwara4a.data.model.user.Self
7 |
8 | val LocalNavController = compositionLocalOf { error("Not Init") }
9 |
10 | val LocalSelfData = compositionLocalOf { Self.GUEST }
11 |
12 | val LocalDarkMode = compositionLocalOf { false }
13 |
14 | val LocalWindowSizeClass = compositionLocalOf { error("Not Init") }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/download/DownloadViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.download
2 |
3 | import androidx.lifecycle.ViewModel
4 | import com.rerere.iwara4a.data.dao.AppDatabase
5 | import com.rerere.iwara4a.data.dao.DownloadedVideoDao
6 | import com.rerere.iwara4a.data.model.download.DownloadingVideo
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import kotlinx.coroutines.channels.Channel
9 | import javax.inject.Inject
10 |
11 | @HiltViewModel
12 | class DownloadViewModel @Inject constructor(
13 | val database: AppDatabase
14 | ) : ViewModel() {
15 | val dao: DownloadedVideoDao = database.getDownloadedVideoDao()
16 | }
17 |
18 | object DownloadingList {
19 | val downloading = Channel>()
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/follow/FollowScreenViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.follow
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.rerere.iwara4a.data.dao.AppDatabase
6 | import com.rerere.iwara4a.data.model.follow.FollowUser
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import kotlinx.coroutines.launch
9 | import javax.inject.Inject
10 |
11 | @HiltViewModel
12 | class FollowScreenViewModel @Inject constructor(
13 | private val database: AppDatabase
14 | ): ViewModel(){
15 | val allUsers = database.getFollowingDao().getAllFollowUsers()
16 |
17 | fun delete(user: FollowUser){
18 | viewModelScope.launch {
19 | database.getFollowingDao().removeUser(user)
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/forum/ForumScreen.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.forum
2 |
3 | import android.webkit.CookieManager
4 | import android.webkit.WebChromeClient
5 | import android.webkit.WebView
6 | import android.webkit.WebViewClient
7 | import androidx.compose.animation.AnimatedVisibility
8 | import androidx.compose.foundation.layout.Box
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.material3.LinearProgressIndicator
13 | import androidx.compose.material3.Scaffold
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.*
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.platform.LocalContext
19 | import androidx.compose.ui.viewinterop.AndroidView
20 | import androidx.hilt.navigation.compose.hiltViewModel
21 | import androidx.lifecycle.ViewModel
22 | import com.rerere.iwara4a.R
23 | import com.rerere.iwara4a.data.model.session.SessionManager
24 | import com.rerere.iwara4a.ui.component.BackIcon
25 | import com.rerere.iwara4a.ui.component.Md3TopBar
26 | import com.rerere.iwara4a.util.stringResource
27 | import dagger.hilt.android.lifecycle.HiltViewModel
28 | import javax.inject.Inject
29 |
30 | @Composable
31 | fun ForumScreen(forumViewModel: ForumViewModel = hiltViewModel()) {
32 | val context = LocalContext.current
33 | var progress by remember {
34 | mutableStateOf(0)
35 | }
36 | var title by remember {
37 | mutableStateOf(context.stringResource(id = R.string.screen_forum_title))
38 | }
39 | Scaffold(
40 | topBar = {
41 | Md3TopBar(
42 | title = {
43 | Text(text = title)
44 | },
45 | navigationIcon = {
46 | BackIcon()
47 | }
48 | )
49 | }
50 | ) { innerPadding ->
51 | Box(Modifier.padding(innerPadding)) {
52 | AndroidView(
53 | modifier = Modifier.fillMaxSize(),
54 | factory = {
55 | WebView(it).apply {
56 | webChromeClient = object : WebChromeClient() {
57 | override fun onProgressChanged(view: WebView?, newProgress: Int) {
58 | progress = newProgress
59 | }
60 |
61 | override fun onReceivedTitle(view: WebView?, title0: String?) {
62 | title = title0 ?: context.stringResource(id = R.string.screen_forum_title)
63 | }
64 | }
65 | webViewClient = WebViewClient()
66 | settings.javaScriptEnabled = true
67 |
68 | CookieManager.getInstance().let { manager ->
69 | manager.acceptCookie()
70 | manager.acceptThirdPartyCookies(this)
71 | manager.setCookie(
72 | ".iwara.tv",
73 | forumViewModel.sessionManager.session.toString()
74 | )
75 | }
76 |
77 | loadUrl("https://ecchi.iwara.tv/forum")
78 | }
79 | }
80 | )
81 |
82 | AnimatedVisibility(
83 | modifier = Modifier.align(Alignment.TopCenter),
84 | visible = progress < 100
85 | ) {
86 | LinearProgressIndicator(modifier = Modifier.fillMaxWidth(),progress = progress / 100f)
87 | }
88 | }
89 | }
90 | }
91 |
92 | @HiltViewModel
93 | class ForumViewModel @Inject constructor(
94 | val sessionManager: SessionManager
95 | ) : ViewModel()
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/friends/FriendsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.friends
2 |
3 | import android.util.Log
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import com.rerere.iwara4a.data.model.friends.FriendList
7 | import com.rerere.iwara4a.data.model.session.SessionManager
8 | import com.rerere.iwara4a.data.repo.UserRepo
9 | import com.rerere.iwara4a.util.DataState
10 | import com.rerere.iwara4a.util.okhttp.await
11 | import dagger.hilt.android.lifecycle.HiltViewModel
12 | import kotlinx.coroutines.Dispatchers
13 | import kotlinx.coroutines.flow.MutableStateFlow
14 | import kotlinx.coroutines.launch
15 | import kotlinx.coroutines.withContext
16 | import okhttp3.FormBody
17 | import okhttp3.OkHttpClient
18 | import okhttp3.Request
19 | import javax.inject.Inject
20 |
21 | private const val TAG = "FriendsViewModel"
22 |
23 | @HiltViewModel
24 | class FriendsViewModel @Inject constructor(
25 | private val okHttpClient: OkHttpClient,
26 | private val sessionManager: SessionManager,
27 | private val userRepo: UserRepo
28 | ) : ViewModel() {
29 | val friendList = MutableStateFlow>(DataState.Empty)
30 |
31 | init {
32 | loadFriendList()
33 | }
34 |
35 | fun loadFriendList() {
36 | viewModelScope.launch {
37 | friendList.value = DataState.Loading
38 | friendList.value = userRepo.getFriendList(sessionManager.session).toDataState()
39 | }
40 | }
41 |
42 | fun handleFriendRequest(id: Int, accept: Boolean, done: () -> Unit) {
43 | viewModelScope.launch {
44 | Log.i(TAG, "handleFriendRequest: edit friend ($id) -> $accept")
45 | withContext(Dispatchers.IO) {
46 | val request = Request.Builder()
47 | .url("https://ecchi.iwara.tv/api/user/friends")
48 | .method(if(accept) "PUT" else "DELETE", FormBody.Builder()
49 | .add("frid", id.toString())
50 | .build())
51 | .build()
52 | okHttpClient.newCall(request).await()
53 | }
54 | done()
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/history/HistoryViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.history
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.rerere.iwara4a.data.dao.AppDatabase
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.launch
8 | import javax.inject.Inject
9 |
10 | @HiltViewModel
11 | class HistoryViewModel @Inject constructor(
12 | private val database: AppDatabase
13 | ) : ViewModel() {
14 | val historyList = database.getHistoryDao().getAllHistory()
15 |
16 | fun clearAll() {
17 | viewModelScope.launch {
18 | database.getHistoryDao().clearAll()
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/index/page/ExplorePage.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.index.page
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.lazy.grid.GridCells
6 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
7 | import androidx.compose.foundation.lazy.grid.items
8 | import androidx.compose.material3.Tab
9 | import androidx.compose.material3.TabRow
10 | import androidx.compose.material3.TabRowDefaults
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.rememberCoroutineScope
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.res.stringResource
16 | import com.google.accompanist.pager.HorizontalPager
17 | import com.google.accompanist.pager.rememberPagerState
18 | import com.rerere.iwara4a.R
19 | import com.rerere.iwara4a.ui.component.MediaPreviewCard
20 | import com.rerere.iwara4a.ui.component.PageList
21 | import com.rerere.iwara4a.ui.component.pagerTabIndicatorOffset
22 | import com.rerere.iwara4a.ui.component.rememberPageListPage
23 | import com.rerere.iwara4a.ui.local.LocalNavController
24 | import com.rerere.iwara4a.ui.screen.index.IndexViewModel
25 | import com.rerere.iwara4a.ui.util.adaptiveGridCell
26 | import kotlinx.coroutines.launch
27 |
28 | @Composable
29 | fun ExplorePage(indexViewModel: IndexViewModel) {
30 | val pagerState = rememberPagerState()
31 | val scope = rememberCoroutineScope()
32 | Column {
33 | TabRow(
34 | selectedTabIndex = pagerState.currentPage,
35 | indicator = {
36 | TabRowDefaults.Indicator(
37 | Modifier.pagerTabIndicatorOffset(pagerState, it)
38 | )
39 | }
40 | ) {
41 | Tab(
42 | selected = pagerState.currentPage == 0,
43 | onClick = {
44 | scope.launch {
45 | pagerState.scrollToPage(0)
46 | }
47 | },
48 | text = {
49 | Text(stringResource(R.string.screen_index_bottom_video))
50 | }
51 | )
52 | Tab(
53 | selected = pagerState.currentPage == 1,
54 | onClick = {
55 | scope.launch {
56 | pagerState.scrollToPage(1)
57 | }
58 | },
59 | text = {
60 | Text(stringResource(R.string.screen_index_bottom_image))
61 | }
62 | )
63 | }
64 | HorizontalPager(
65 | modifier = Modifier.fillMaxSize(),
66 | state = pagerState,
67 | count = 2
68 | ) {
69 | when (it) {
70 | 0 -> {
71 | VideoListPage(indexViewModel)
72 | }
73 | 1 -> {
74 | ImageListPage(indexViewModel)
75 | }
76 | }
77 | }
78 | }
79 | }
80 |
81 | @Composable
82 | fun VideoListPage(indexViewModel: IndexViewModel) {
83 | val pageListState = rememberPageListPage()
84 | PageList(
85 | state = pageListState,
86 | provider = indexViewModel.videoListPrvider,
87 | supportQueryParam = true
88 | ) { list ->
89 | LazyVerticalGrid(
90 | columns = GridCells.Fixed(2),
91 | ) {
92 | items(list) {
93 | MediaPreviewCard(LocalNavController.current, it)
94 | }
95 | }
96 | }
97 | }
98 |
99 | @Composable
100 | fun ImageListPage(indexViewModel: IndexViewModel) {
101 | val pageListState = rememberPageListPage()
102 | PageList(
103 | state = pageListState,
104 | provider = indexViewModel.imageListProvider,
105 | supportQueryParam = true
106 | ) { list ->
107 | LazyVerticalGrid(columns = adaptiveGridCell()) {
108 | items(list) {
109 | MediaPreviewCard(LocalNavController.current, it)
110 | }
111 | }
112 | }
113 | }
--------------------------------------------------------------------------------
/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.lazy.grid.LazyVerticalGrid
4 | import androidx.compose.foundation.lazy.grid.items
5 | import androidx.compose.runtime.Composable
6 | import com.rerere.iwara4a.ui.component.MediaPreviewCard
7 | import com.rerere.iwara4a.ui.component.PageList
8 | import com.rerere.iwara4a.ui.component.rememberPageListPage
9 | import com.rerere.iwara4a.ui.local.LocalNavController
10 | import com.rerere.iwara4a.ui.screen.index.IndexViewModel
11 | import com.rerere.iwara4a.ui.util.adaptiveGridCell
12 |
13 | @Composable
14 | fun SubPage(indexViewModel: IndexViewModel) {
15 | val pageListState = rememberPageListPage()
16 | PageList(
17 | state = pageListState,
18 | provider = indexViewModel.subscriptionsProvider
19 | ) { list ->
20 | LazyVerticalGrid(
21 | columns = adaptiveGridCell()
22 | ) {
23 | items(list) {
24 | MediaPreviewCard(LocalNavController.current, it)
25 | }
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/like/LikedViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.like
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.paging.Pager
6 | import androidx.paging.PagingConfig
7 | import androidx.paging.cachedIn
8 | import com.rerere.iwara4a.data.api.paging.LikeSource
9 | import com.rerere.iwara4a.data.model.session.SessionManager
10 | import com.rerere.iwara4a.data.repo.MediaRepo
11 | import dagger.hilt.android.lifecycle.HiltViewModel
12 | import javax.inject.Inject
13 |
14 | @HiltViewModel
15 | class LikedViewModel @Inject constructor(
16 | private val sessionManager: SessionManager,
17 | private val mediaRepo: MediaRepo
18 | ) : ViewModel() {
19 | val pager = Pager(
20 | config = PagingConfig(
21 | pageSize = 20,
22 | initialLoadSize = 20,
23 | prefetchDistance = 4
24 | )
25 | ) {
26 | LikeSource(
27 | sessionManager = sessionManager,
28 | mediaRepo = mediaRepo
29 | )
30 | }.flow.cachedIn(viewModelScope)
31 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/log/LogScreen.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.log
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.foundation.lazy.items
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.outlined.Delete
8 | import androidx.compose.material3.*
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.runtime.produceState
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.platform.LocalContext
14 | import androidx.compose.ui.unit.dp
15 | import com.elvishew.xlog.LogLevel
16 | import com.elvishew.xlog.printer.file.naming.DateFileNameGenerator
17 | import com.rerere.iwara4a.ui.component.BackIcon
18 | import com.rerere.iwara4a.ui.component.Md3TopBar
19 | import com.rerere.iwara4a.ui.util.plus
20 | import com.rerere.iwara4a.util.LogEntry
21 | import com.rerere.iwara4a.util.format
22 |
23 | @Composable
24 | fun LogScreen() {
25 | val context = LocalContext.current
26 | val content by produceState(initialValue = emptyList()) {
27 | val file = context.cacheDir
28 | .resolve("logs")
29 | .resolve(
30 | DateFileNameGenerator()
31 | .generateFileName(0, System.currentTimeMillis())
32 | )
33 | if (file.exists()) {
34 | value = file.readLines()
35 | .filter {
36 | it.isNotEmpty()
37 | }.map {
38 | try {
39 | LogEntry.fromString(it.trim())
40 | } catch (e: Exception) {
41 | LogEntry(
42 | message = it
43 | )
44 | }
45 | }
46 | }
47 | }
48 | Scaffold(
49 | topBar = {
50 | Md3TopBar(
51 | title = {
52 | Text("Log")
53 | },
54 | navigationIcon = {
55 | BackIcon()
56 | },
57 | actions = {
58 | IconButton(
59 | onClick = {
60 | val file = context.cacheDir
61 | .resolve("logs")
62 | .resolve(
63 | DateFileNameGenerator()
64 | .generateFileName(0, System.currentTimeMillis())
65 | )
66 | if (file.exists()) {
67 | file.writeText("")
68 | }
69 | }
70 | ) {
71 | Icon(Icons.Outlined.Delete, null)
72 | }
73 | }
74 | )
75 | }
76 | ) { innerPadding ->
77 | LazyColumn(
78 | contentPadding = innerPadding
79 | + WindowInsets.navigationBars.asPaddingValues()
80 | + PaddingValues(horizontal = 8.dp),
81 | verticalArrangement = Arrangement.spacedBy(4.dp)
82 | ) {
83 | items(content) {
84 | LogEntry(it)
85 | }
86 | }
87 | }
88 | }
89 |
90 | @Composable
91 | private fun LogEntry(entry: LogEntry) {
92 | Card {
93 | Column(
94 | modifier = Modifier
95 | .padding(8.dp)
96 | .fillMaxWidth(),
97 | verticalArrangement = Arrangement.spacedBy(4.dp)
98 | ) {
99 | Row(
100 | horizontalArrangement = Arrangement.SpaceEvenly,
101 | modifier = Modifier.fillMaxWidth()
102 | ) {
103 | Text(
104 | text = entry.time.format(true)
105 | )
106 | Text(
107 | text = entry.thread
108 | )
109 | Text(
110 | text = LogLevel.getLevelName(entry.level)
111 | )
112 | }
113 |
114 | Divider()
115 |
116 | Text(
117 | text = entry.message,
118 | style = MaterialTheme.typography.bodySmall
119 | )
120 | }
121 | }
122 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/login/LoginViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.login
2 |
3 | import android.app.Application
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.setValue
7 | import androidx.core.content.edit
8 | import androidx.lifecycle.ViewModel
9 | import androidx.lifecycle.viewModelScope
10 | import com.rerere.iwara4a.data.model.session.SessionManager
11 | import com.rerere.iwara4a.data.repo.UserRepo
12 | import com.rerere.iwara4a.sharedPreferencesOf
13 | import com.rerere.iwara4a.ui.activity.RouterViewModel
14 | import dagger.hilt.android.lifecycle.HiltViewModel
15 | import kotlinx.coroutines.delay
16 | import kotlinx.coroutines.launch
17 | import javax.inject.Inject
18 |
19 | @HiltViewModel
20 | class LoginViewModel @Inject constructor(
21 | private val userRepo: UserRepo,
22 | private val sessionManager: SessionManager,
23 | private val context: Application
24 | ) : ViewModel() {
25 | var userName by mutableStateOf("")
26 | var password by mutableStateOf("")
27 | var isLoginState by mutableStateOf(false)
28 | var errorContent by mutableStateOf("")
29 |
30 | init {
31 | val sharedPreferences = context.sharedPreferencesOf("session")
32 | userName = sharedPreferences.getString("username", "")!!
33 | password = sharedPreferences.getString("password", "")!!
34 | }
35 |
36 | fun isValidInput(): Boolean {
37 | if(userName.contains("\n")){
38 | return false
39 | }
40 | if(password.contains("\n")){
41 | return false
42 | }
43 | return true
44 | }
45 |
46 | fun login(viewModel: RouterViewModel, result: (success: Boolean) -> Unit) {
47 | viewModelScope.launch {
48 | isLoginState = true
49 | // save
50 | val sharedPreferences = context.sharedPreferencesOf("session")
51 | sharedPreferences.edit {
52 | putString("username", userName)
53 | putString("password", password)
54 | }
55 |
56 | val response = userRepo.login(userName, password)
57 |
58 | // call event
59 | if (response.isSuccess()) {
60 | val session = response.read()
61 | sessionManager.update(session.key, session.value)
62 | viewModel.prepareUserData()
63 | } else {
64 | errorContent = response.errorMessage()
65 | }
66 |
67 | delay(50)
68 | // call back
69 | result(response.isSuccess())
70 |
71 | isLoginState = false
72 | }
73 | }
74 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/message/MessageScreen.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.message
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.material3.Scaffold
5 | import androidx.compose.material3.Text
6 | import androidx.compose.runtime.*
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.platform.LocalContext
9 | import androidx.hilt.navigation.compose.hiltViewModel
10 | import com.rerere.iwara4a.R
11 | import com.rerere.iwara4a.ui.component.BackIcon
12 | import com.rerere.iwara4a.ui.component.ComposeWebview
13 | import com.rerere.iwara4a.ui.component.Md3TopBar
14 | import com.rerere.iwara4a.ui.local.LocalNavController
15 | import com.rerere.iwara4a.util.stringResource
16 |
17 | @Composable
18 | fun MessageScreen(
19 | messageViewModel: MessageViewModel = hiltViewModel()
20 | ) {
21 | val navController = LocalNavController.current
22 | val context = LocalContext.current
23 | var title by remember {
24 | mutableStateOf(context.stringResource(id = R.string.screen_message_topbar_title))
25 | }
26 | Scaffold(topBar = {
27 | Md3TopBar(
28 | title = {
29 | Text(text = title)
30 | },
31 | navigationIcon = {
32 | BackIcon()
33 | }
34 | )
35 | }) { padding ->
36 | ComposeWebview(
37 | modifier = Modifier.padding(padding),
38 | link = "https://ecchi.iwara.tv/messages",
39 | session = messageViewModel.sessionManager.session
40 | ) {
41 | title = it
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/message/MessageViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.message
2 |
3 | import androidx.lifecycle.ViewModel
4 | import com.rerere.iwara4a.data.model.session.SessionManager
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import javax.inject.Inject
7 |
8 | @HiltViewModel
9 | class MessageViewModel @Inject constructor(
10 | val sessionManager: SessionManager
11 | ) : ViewModel()
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/playlist/new/PlaylistScreen.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.playlist.new
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.material3.Scaffold
6 | import androidx.compose.material3.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.res.stringResource
10 | import androidx.hilt.navigation.compose.hiltViewModel
11 | import com.rerere.iwara4a.R
12 | import com.rerere.iwara4a.ui.component.Md3TopBar
13 |
14 | @Composable
15 | fun PlaylistScreen(
16 | viewModel: PlaylistViewModel = hiltViewModel()
17 | ) {
18 | Scaffold(
19 | topBar = {
20 | Md3TopBar(
21 | title = {
22 | Text(stringResource(R.string.screen_playlist_dialog_topbar_title))
23 | }
24 | )
25 | }
26 | ) { innerPadding ->
27 | Column(
28 | modifier = Modifier
29 | .padding(innerPadding)
30 | ) {
31 |
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/playlist/new/PlaylistViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.playlist.new
2 |
3 | import androidx.lifecycle.SavedStateHandle
4 | import androidx.lifecycle.ViewModel
5 | import com.rerere.iwara4a.data.repo.MediaRepo
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import javax.inject.Inject
8 |
9 | @HiltViewModel
10 | class PlaylistViewModel @Inject constructor(
11 | private val mediaRepo: MediaRepo,
12 | savedStateHandle: SavedStateHandle
13 | ) : ViewModel() {
14 | val playlistId: String? = savedStateHandle["playlistId"]
15 | }
--------------------------------------------------------------------------------
/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 com.rerere.iwara4a.data.api.Response
9 | import com.rerere.iwara4a.data.model.index.MediaPreview
10 | import com.rerere.iwara4a.data.model.session.SessionManager
11 | import com.rerere.iwara4a.data.repo.MediaRepo
12 | import com.rerere.iwara4a.ui.component.MediaQueryParam
13 | import com.rerere.iwara4a.ui.component.PageListProvider
14 | import com.rerere.iwara4a.ui.component.SortType
15 | import com.rerere.iwara4a.util.DataState
16 | import dagger.hilt.android.lifecycle.HiltViewModel
17 | import kotlinx.coroutines.flow.Flow
18 | import kotlinx.coroutines.flow.MutableStateFlow
19 | import kotlinx.coroutines.launch
20 | import javax.inject.Inject
21 |
22 | @HiltViewModel
23 | class SearchViewModel @Inject constructor(
24 | private val sessionManager: SessionManager,
25 | private val mediaRepo: MediaRepo
26 | ) : ViewModel() {
27 | var query by mutableStateOf("")
28 |
29 | val provider = object : PageListProvider {
30 | private var lastSuccessPage = -1
31 | private var lastSuccessQueryParam: MediaQueryParam? = MediaQueryParam.Default
32 | private var lastQuery = ""
33 | private val data = MutableStateFlow>>(DataState.Empty)
34 |
35 | override fun load(page: Int, queryParam: MediaQueryParam?) {
36 | if(query.isBlank()) return
37 | if(page == lastSuccessPage && query == lastQuery && queryParam == lastSuccessQueryParam) return
38 |
39 | viewModelScope.launch {
40 | data.value = DataState.Loading
41 | val response = mediaRepo.search(
42 | session = sessionManager.session,
43 | query = query,
44 | page = page - 1,
45 | sort = queryParam?.sortType ?: SortType.DATE,
46 | filter = queryParam?.filters ?: hashSetOf()
47 | )
48 | when(response){
49 | is Response.Success -> {
50 | data.value = DataState.Success(response.read().mediaList)
51 |
52 | lastSuccessPage = page
53 | lastQuery = query
54 | lastSuccessQueryParam = queryParam
55 | }
56 | is Response.Failed -> {
57 | data.value = DataState.Error(response.errorMessage())
58 | }
59 | }
60 | }
61 | }
62 |
63 | override fun getPage(): Flow>> = data
64 | }
65 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/self/SelfScreen.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.self
2 |
3 | import android.webkit.CookieManager
4 | import android.webkit.WebChromeClient
5 | import android.webkit.WebView
6 | import android.webkit.WebViewClient
7 | import androidx.compose.animation.AnimatedVisibility
8 | import androidx.compose.foundation.layout.Box
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.material3.LinearProgressIndicator
13 | import androidx.compose.material3.Scaffold
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.*
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.res.stringResource
19 | import androidx.compose.ui.viewinterop.AndroidView
20 | import androidx.hilt.navigation.compose.hiltViewModel
21 | import com.rerere.iwara4a.R
22 | import com.rerere.iwara4a.ui.component.BackIcon
23 | import com.rerere.iwara4a.ui.component.Md3TopBar
24 | import com.rerere.iwara4a.ui.local.LocalSelfData
25 |
26 | @Composable
27 | fun SelfScreen(
28 | selfViewModel: SelfViewModel = hiltViewModel()
29 | ) {
30 | var progress by remember {
31 | mutableStateOf(0)
32 | }
33 | Scaffold(
34 | topBar = {
35 | Md3TopBar(
36 | title = {
37 | Text(text = stringResource(id = R.string.screen_self_topbar_title))
38 | },
39 | navigationIcon = {
40 | BackIcon()
41 | }
42 | )
43 | }
44 | ) { padding ->
45 | val self = LocalSelfData.current
46 | Box(
47 | modifier = Modifier.padding(padding)
48 | ) {
49 | AndroidView(
50 | modifier = Modifier.fillMaxSize(),
51 | factory = {
52 | WebView(it).apply {
53 | webChromeClient = object : WebChromeClient() {
54 | override fun onProgressChanged(view: WebView?, newProgress: Int) {
55 | progress = newProgress
56 | }
57 | }
58 | webViewClient = WebViewClient()
59 | settings.javaScriptEnabled = true
60 |
61 | CookieManager.getInstance().let { manager ->
62 | manager.acceptCookie()
63 | manager.acceptThirdPartyCookies(this)
64 | manager.setCookie(
65 | ".iwara.tv",
66 | selfViewModel.sessionManager.session.toString()
67 | )
68 | }
69 |
70 | loadUrl("https://ecchi.iwara.tv/user/${self.numId}/edit")
71 | }
72 | }
73 | )
74 |
75 | AnimatedVisibility(
76 | modifier = Modifier.align(Alignment.TopCenter),
77 | visible = progress < 100
78 | ) {
79 | LinearProgressIndicator(modifier = Modifier.fillMaxWidth(),progress = progress / 100f)
80 | }
81 | }
82 | }
83 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/self/SelfViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.self
2 |
3 | import androidx.lifecycle.ViewModel
4 | import com.rerere.iwara4a.data.model.session.SessionManager
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import javax.inject.Inject
7 |
8 | @HiltViewModel
9 | class SelfViewModel @Inject constructor(
10 | val sessionManager: SessionManager
11 | ) : ViewModel() {
12 | }
--------------------------------------------------------------------------------
/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.animation.AnimatedVisibility
4 | import androidx.compose.animation.animateContentSize
5 | import androidx.compose.foundation.background
6 | import androidx.compose.foundation.layout.*
7 | import androidx.compose.foundation.shape.CircleShape
8 | import androidx.compose.material3.LinearProgressIndicator
9 | import androidx.compose.material3.MaterialTheme
10 | import androidx.compose.material3.Text
11 | import androidx.compose.runtime.Composable
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.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 coil.compose.AsyncImage
22 | import com.rerere.iwara4a.R
23 |
24 | @Composable
25 | fun SplashScreen(navController: NavController, splashViewModel: SplashViewModel = hiltViewModel()) {
26 | Box(
27 | modifier = Modifier
28 | .fillMaxSize()
29 | .navigationBarsPadding()
30 | .background(MaterialTheme.colorScheme.background),
31 | contentAlignment = Alignment.BottomCenter
32 | ) {
33 | Column(
34 | modifier = Modifier
35 | .animateContentSize()
36 | .wrapContentSize()
37 | .padding(16.dp),
38 | horizontalAlignment = Alignment.CenterHorizontally
39 | ) {
40 | Row(verticalAlignment = Alignment.CenterVertically) {
41 | AsyncImage(
42 | modifier = Modifier
43 | .size(70.dp)
44 | .clip(CircleShape),
45 | model = R.drawable.miku,
46 | contentDescription = null
47 | )
48 |
49 | Spacer(modifier = Modifier.width(20.dp))
50 | Column {
51 | Text(
52 | text = "Iwara",
53 | fontSize = 35.sp,
54 | fontWeight = FontWeight.Bold,
55 | color = MaterialTheme.colorScheme.onBackground
56 | )
57 | Text(
58 | text = "ecchi.iwara.tv",
59 | fontSize = 20.sp,
60 | color = MaterialTheme.colorScheme.onBackground
61 | )
62 | }
63 | }
64 | Spacer(modifier = Modifier.height(50.dp))
65 | AnimatedVisibility(splashViewModel.checkingCookkie) {
66 | Column(
67 | horizontalAlignment = Alignment.CenterHorizontally,
68 | verticalArrangement = Arrangement.spacedBy(20.dp)
69 | ) {
70 | LinearProgressIndicator(
71 | modifier = Modifier.width(150.dp)
72 | )
73 | }
74 | }
75 | }
76 | }
77 | LaunchedEffect(
78 | splashViewModel.checked,
79 | splashViewModel.cookieValid,
80 | splashViewModel.checkingCookkie
81 | ) {
82 | if (splashViewModel.checked && !splashViewModel.checkingCookkie) {
83 | // 前往主页
84 | if (splashViewModel.cookieValid) {
85 | navController.navigate("index") {
86 | popUpTo("splash") {
87 | inclusive = true
88 | }
89 | }
90 | } else {
91 | // 登录
92 | navController.navigate("login") {
93 | popUpTo("splash") {
94 | inclusive = true
95 | }
96 | }
97 | }
98 | }
99 | }
100 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/splash/SplashViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.splash
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.data.model.session.SessionManager
9 | import com.rerere.iwara4a.data.repo.UserRepo
10 | import dagger.hilt.android.lifecycle.HiltViewModel
11 | import kotlinx.coroutines.delay
12 | import kotlinx.coroutines.launch
13 | import javax.inject.Inject
14 |
15 | @HiltViewModel
16 | class SplashViewModel @Inject constructor(
17 | private val sessionManager: SessionManager,
18 | private val userRepo: UserRepo
19 | ) : ViewModel() {
20 | private fun isLogin() = sessionManager.session.key.isNotEmpty()
21 |
22 | var checked by mutableStateOf(false)
23 | var checkingCookkie by mutableStateOf(false)
24 | var cookieValid by mutableStateOf(false)
25 | var firstTime by mutableStateOf(false)
26 |
27 | init {
28 | viewModelScope.launch {
29 | checkingCookkie = true
30 | if (isLogin()) {
31 | val info = userRepo.getSelf(sessionManager.session)
32 | if (info.isSuccess()) {
33 | cookieValid = true
34 | } else {
35 | cookieValid = false
36 | println(info.errorMessage())
37 | }
38 | } else {
39 | firstTime = true
40 | delay(1000)
41 | cookieValid = false
42 | }
43 |
44 | checkingCookkie = false
45 | checked = true
46 | }
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/screen/video/tabs/VideoScreenSimilarVideoTab.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.screen.video.tabs
2 |
3 | import androidx.compose.foundation.layout.WindowInsets
4 | import androidx.compose.foundation.layout.asPaddingValues
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.navigationBars
7 | import androidx.compose.foundation.lazy.grid.GridCells
8 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
9 | import androidx.compose.foundation.lazy.grid.items
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.unit.dp
13 | import com.rerere.iwara4a.data.model.detail.video.VideoDetail
14 | import com.rerere.iwara4a.data.model.index.MediaPreview
15 | import com.rerere.iwara4a.data.model.index.MediaType
16 | import com.rerere.iwara4a.ui.component.MediaPreviewCard
17 | import com.rerere.iwara4a.ui.local.LocalNavController
18 |
19 | @Composable
20 | fun VideoScreenSimilarVideoTab(videoDetail: VideoDetail) {
21 | val navController = LocalNavController.current
22 | LazyVerticalGrid(
23 | columns = GridCells.Adaptive(140.dp),
24 | modifier = Modifier.fillMaxSize(),
25 | contentPadding = WindowInsets.navigationBars.asPaddingValues()
26 | ) {
27 | items(videoDetail.recommendVideo.filter { it.title.isNotEmpty() }) {
28 | MediaPreviewCard(
29 | navController, MediaPreview(
30 | title = it.title,
31 | author = "",
32 | previewPic = it.pic,
33 | likes = it.likes,
34 | watchs = it.watchs,
35 | type = MediaType.VIDEO,
36 | mediaId = it.id
37 | )
38 | )
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/states/ClipboardState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.states
2 |
3 | import android.content.ClipData
4 | import android.content.ClipboardManager
5 | import android.os.Build
6 | import androidx.compose.runtime.*
7 | import androidx.compose.ui.platform.LocalContext
8 |
9 | @Composable
10 | fun rememberPrimaryClipboardState(): MutableState {
11 | val context = LocalContext.current
12 | val service = remember { context.getSystemService(ClipboardManager::class.java) }
13 | val state = remember { mutableStateOf(service.primaryClip) }
14 | DisposableEffect(Unit) {
15 | val listener = ClipboardManager.OnPrimaryClipChangedListener {
16 | state.value = service.primaryClip
17 | }
18 | service.addPrimaryClipChangedListener(listener)
19 | onDispose {
20 | service.removePrimaryClipChangedListener(listener)
21 | }
22 | }
23 | return object : MutableState {
24 | override var value: ClipData?
25 | get() = state.value
26 | set(value) {
27 | value?.let {
28 | service.setPrimaryClip(it)
29 | } ?: kotlin.run {
30 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
31 | service.clearPrimaryClip()
32 | } else {
33 | service.setPrimaryClip(ClipData.newPlainText("",""))
34 | }
35 | }
36 | }
37 |
38 | override fun component1(): ClipData? = value
39 |
40 | override fun component2(): (ClipData?) -> Unit = { value = it }
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/states/IntentState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.states
2 |
3 | import android.content.Intent
4 | import androidx.activity.ComponentActivity
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.DisposableEffect
7 | import androidx.compose.ui.platform.LocalContext
8 | import com.rerere.iwara4a.util.findActivity
9 |
10 | /**
11 | * 处理activity的intent
12 | */
13 | @Composable
14 | fun OnNewIntentListener(handler: (Intent) -> Unit) {
15 | val context = LocalContext.current
16 | DisposableEffect(Unit){
17 | val activity = context.findActivity() as ComponentActivity
18 | activity.addOnNewIntentListener(handler)
19 | onDispose {
20 | activity.removeOnNewIntentListener(handler)
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/states/LifecycleState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.states
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.DisposableEffect
5 | import androidx.compose.runtime.rememberUpdatedState
6 | import androidx.compose.ui.platform.LocalLifecycleOwner
7 | import androidx.lifecycle.Lifecycle
8 | import androidx.lifecycle.LifecycleEventObserver
9 | import androidx.lifecycle.LifecycleOwner
10 |
11 | @Composable
12 | fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) {
13 | val eventHandler = rememberUpdatedState(onEvent)
14 | val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
15 |
16 | DisposableEffect(lifecycleOwner.value) {
17 | val lifecycle = lifecycleOwner.value.lifecycle
18 | val observer = LifecycleEventObserver { owner, event ->
19 | eventHandler.value(owner, event)
20 | }
21 | lifecycle.addObserver(observer)
22 | onDispose {
23 | lifecycle.removeObserver(observer)
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/states/PIPModeState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.states
2 |
3 | import androidx.activity.ComponentActivity
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.DisposableEffect
6 | import androidx.compose.ui.platform.LocalContext
7 | import androidx.core.app.PictureInPictureModeChangedInfo
8 | import com.rerere.iwara4a.util.findActivity
9 |
10 | @Composable
11 | fun PipModeListener(handler: (PictureInPictureModeChangedInfo) -> Unit) {
12 | val activity = LocalContext.current.findActivity() as ComponentActivity
13 | DisposableEffect(Unit){
14 | activity.addOnPictureInPictureModeChangedListener(handler)
15 | onDispose {
16 | activity.removeOnPictureInPictureModeChangedListener(handler)
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/states/ServiceState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.states
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.remember
5 | import androidx.compose.ui.platform.LocalContext
6 |
7 | @Composable
8 | inline fun rememberSystemService(): T {
9 | val context = LocalContext.current
10 | return remember(context) {
11 | context.getSystemService(T::class.java)
12 | }
13 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.theme
2 |
3 | import android.app.Activity
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.CompositionLocalProvider
9 | import androidx.compose.runtime.SideEffect
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.graphics.toArgb
13 | import androidx.compose.ui.platform.LocalContext
14 | import androidx.compose.ui.platform.LocalView
15 | import androidx.core.view.WindowCompat
16 | import com.rerere.iwara4a.ui.local.LocalDarkMode
17 | import com.rerere.iwara4a.ui.local.LocalWindowSizeClass
18 | import com.rerere.iwara4a.util.findActivity
19 | import me.rerere.compose_setting.preference.rememberIntPreference
20 | import me.rerere.md3compat.Md3CompatTheme
21 |
22 | @Composable
23 | fun Iwara4aTheme(
24 | content: @Composable () -> Unit
25 | ) {
26 | val context = LocalContext.current
27 | val nightMode by rememberIntPreference(key = "nightMode", default = 0)
28 | val darkTheme = when(nightMode) {
29 | 0 -> isSystemInDarkTheme()
30 | 1 -> false
31 | 2 -> true
32 | else -> isSystemInDarkTheme()
33 | }
34 | val windowClass = calculateWindowSizeClass(context.findActivity())
35 | CompositionLocalProvider(
36 | LocalDarkMode provides darkTheme,
37 | LocalWindowSizeClass provides windowClass
38 | ) {
39 | ApplyBarColor()
40 | Md3CompatTheme(
41 | darkTheme = darkTheme,
42 | // colorScheme = if(darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context),
43 | typography = Typography
44 | ) {
45 | // MD2 Compat
46 | androidx.compose.material.MaterialTheme(
47 | colors = MaterialTheme.colorScheme.toLegacyColor(darkTheme),
48 | content = content
49 | )
50 | }
51 | }
52 | }
53 |
54 | @Composable
55 | fun ApplyBarColor(darkTheme: Boolean = LocalDarkMode.current) {
56 | val view = LocalView.current
57 | val activity = LocalContext.current as Activity
58 | SideEffect {
59 | view.context.findActivity().window.apply {
60 | statusBarColor = Color.Transparent.toArgb()
61 | navigationBarColor = Color.Transparent.toArgb()
62 | }
63 | WindowCompat.getInsetsController(activity.window,view).apply {
64 | isAppearanceLightNavigationBars = !darkTheme
65 | isAppearanceLightStatusBars = !darkTheme
66 | }
67 | }
68 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 |
5 | // Set of Material typography styles to start with
6 | val Typography = Typography()
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/util/FastRememberState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.util
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.remember
6 |
7 | @Composable
8 | fun rememberMutableState(value: T) = remember {
9 | mutableStateOf(value)
10 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/util/GridCell.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.util
2 |
3 | import androidx.compose.foundation.lazy.grid.GridCells
4 | import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
5 | import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.platform.LocalContext
8 | import androidx.compose.ui.unit.dp
9 | import com.rerere.iwara4a.util.findActivity
10 |
11 | @Composable
12 | fun adaptiveGridCell(): GridCells {
13 | val windowSizeClass = calculateWindowSizeClass(LocalContext.current.findActivity())
14 | return when(windowSizeClass.widthSizeClass) {
15 | WindowWidthSizeClass.Compact -> GridCells.Fixed(2)
16 | else -> GridCells.Adaptive(200.dp)
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/util/MemorySaver.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("UNCHECKED_CAST")
2 |
3 | package com.rerere.iwara4a.ui.util
4 |
5 | import androidx.compose.runtime.saveable.Saver
6 | import java.util.*
7 |
8 | private const val MAX_SIZE = 100
9 | private val cache = hashMapOf>()
10 |
11 | /**
12 | * 基于内存的Saver
13 | */
14 | fun memSaver() = Saver(
15 | save = {
16 | UUID.randomUUID().toString().also { key ->
17 | cache[key] = MemorySaverEntry(it, System.currentTimeMillis())
18 | }
19 | },
20 | restore = { key ->
21 | val value = cache[key]?.value
22 |
23 | // 移出map
24 | cache.remove(key)
25 |
26 | // 限制最大数量
27 | if(cache.size > MAX_SIZE) {
28 | val removes = cache.size - MAX_SIZE
29 | cache.entries
30 | .sortedBy { entry -> entry.value.saveTime }
31 | .take(removes)
32 | .forEach {
33 | cache.remove(it.key)
34 | }
35 | }
36 |
37 | value as? T
38 | }
39 | )
40 |
41 | private class MemorySaverEntry(
42 | val value: T,
43 | val saveTime: Long
44 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/util/PaddingExtension.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.util
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.calculateEndPadding
5 | import androidx.compose.foundation.layout.calculateStartPadding
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.platform.LocalLayoutDirection
8 |
9 | @Composable
10 | operator fun PaddingValues.plus(other: PaddingValues): PaddingValues =
11 | PaddingValues(
12 | top = calculateTopPadding() + other.calculateTopPadding(),
13 | bottom = calculateBottomPadding() + other.calculateBottomPadding(),
14 | start = calculateStartPadding(LocalLayoutDirection.current) + other.calculateStartPadding(LocalLayoutDirection.current),
15 | end = calculateEndPadding(LocalLayoutDirection.current) + other.calculateEndPadding(LocalLayoutDirection.current)
16 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/util/PreviewAll.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.util
2 |
3 | import androidx.compose.ui.tooling.preview.Preview
4 |
5 | @Preview(
6 | name = "手机",
7 | showBackground = true,
8 | device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480"
9 | )
10 | @Preview(
11 | name = "平板",
12 | showBackground = true,
13 | device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480"
14 | )
15 | annotation class PreviewDevices
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/ui/util/StringResourceByName.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.ui.util
2 |
3 | import android.content.res.Resources
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.ReadOnlyComposable
6 | import androidx.compose.ui.platform.LocalConfiguration
7 | import androidx.compose.ui.platform.LocalContext
8 |
9 | @Composable
10 | @ReadOnlyComposable
11 | fun stringResourceByName(name: String): String {
12 | val contex = LocalContext.current
13 | val resources = resources()
14 | val id = resources.getIdentifier(name, "string", contex.packageName)
15 | if(id == 0) return name
16 | return resources.getString(
17 | id
18 | )
19 | }
20 |
21 | @Composable
22 | @ReadOnlyComposable
23 | private fun resources(): Resources {
24 | LocalConfiguration.current
25 | return LocalContext.current.resources
26 | }
--------------------------------------------------------------------------------
/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.data.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 = 3, // 重连次数
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(
26 | TAG,
27 | "autoRetry: Successful get response (${System.currentTimeMillis() - start} ms)"
28 | )
29 | return response
30 | }
31 | }
32 | Log.i(TAG, "autoRetry: Try to get response: $maxRetry*/$maxRetry")
33 | return action()
34 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/ComposeHacking.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util
2 |
3 | /**
4 | * Use reflection to modify some internal fields of the compose components.
5 | *
6 | * Compose is still in the rapid development stage, many properties are not
7 | * exposed for customization, so use reflection to modify internal properties
8 | *
9 | * @author RE
10 | * @return Modify Result
11 | */
12 | fun initComposeHacking() = runCatching {
13 | hackMinTabWidth()
14 | }
15 |
16 | // https://issuetracker.google.com/issues/226665301
17 | private fun hackMinTabWidth() {
18 | val clazz = Class.forName("androidx.compose.material3.TabRowKt")
19 | val field = clazz.getDeclaredField("ScrollableTabRowMinimumTabWidth")
20 | field.isAccessible = true
21 | field.set(null, 0.0f) // set min width to zero (fit content width)
22 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/DataState.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | sealed class DataState {
6 | object Empty : DataState()
7 | object Loading : DataState()
8 |
9 | data class Error(
10 | val message: String
11 | ) : DataState()
12 |
13 | data class Success(
14 | val data: T
15 | ) : DataState()
16 |
17 | fun read(): T = (this as Success).data
18 |
19 | fun readSafely(): T? = if (this is Success) read() else null
20 | }
21 |
22 | @Composable
23 | fun DataState.onSuccess(
24 | content: @Composable ((T) -> Unit)
25 | ): DataState {
26 | if (this is DataState.Success) {
27 | content(this.data)
28 | }
29 | return this
30 | }
31 |
32 | @Composable
33 | fun DataState.onError(
34 | content: @Composable ((String) -> Unit)
35 | ): DataState {
36 | if (this is DataState.Error) {
37 | content(this.message)
38 | }
39 | return this
40 | }
41 |
42 | @Composable
43 | fun DataState.onLoading(
44 | content: @Composable (() -> Unit)
45 | ): DataState {
46 | if (this is DataState.Loading) {
47 | content()
48 | }
49 | return this
50 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/DownloadUtil.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import com.rerere.iwara4a.data.model.detail.video.VideoDetail
6 | import com.rerere.iwara4a.service.DownloadService
7 |
8 | @JvmInline
9 | value class FileSize(
10 | private val bytes: Long
11 | ) {
12 | override fun toString(): String {
13 | return if (bytes < 1024) {
14 | "$bytes B"
15 | } else if (bytes < 1024 * 1024) {
16 | "${bytes / 1024} KB"
17 | } else if (bytes < 1024 * 1024 * 1024) {
18 | "${bytes / 1024 / 1024} MB"
19 | } else {
20 | "${bytes / 1024 / 1024 / 1024} GB"
21 | }
22 | }
23 | }
24 |
25 | fun Context.downloadVideo(videoDetail: VideoDetail, url: String) {
26 | startService(Intent(
27 | this,
28 | DownloadService::class.java
29 | ).apply {
30 | putExtra("nid", videoDetail.nid)
31 | putExtra("title", videoDetail.title)
32 | putExtra("url", url)
33 | putExtra("preview", videoDetail.preview)
34 | })
35 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/LogUtil.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util
2 |
3 | import com.elvishew.xlog.LogLevel
4 | import com.elvishew.xlog.XLog
5 | import com.google.gson.Gson
6 |
7 | private val gson by lazy { Gson() }
8 |
9 | data class LogEntry(
10 | val time: Long = System.currentTimeMillis(),
11 | val level: Int = LogLevel.INFO,
12 | val tag: String = "iwara4a",
13 | val thread: String = Thread.currentThread().name,
14 | val message: String = "null"
15 | ) {
16 | override fun toString(): String {
17 | return gson.toJson(this)
18 | }
19 |
20 | companion object {
21 | fun fromString(s: String): LogEntry {
22 | return gson.fromJson(s, LogEntry::class.java)
23 | }
24 | }
25 | }
26 |
27 | fun logInfo(message: String) {
28 | XLog.i(message)
29 | }
30 |
31 | fun logError(message: String = "", throwable: Throwable) {
32 | XLog.e(message, throwable)
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/Maths.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util
2 |
3 | import kotlin.math.roundToInt
4 |
5 | fun Float.format() = (this * 1000).roundToInt() / 1000.0
6 | fun Float.formatToString() = format().toString()
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/TimeUtil.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util
2 |
3 | import android.annotation.SuppressLint
4 | import java.text.SimpleDateFormat
5 | import java.util.*
6 |
7 |
8 | @SuppressLint("SimpleDateFormat")
9 | private val sdf = SimpleDateFormat("yyyy-MM-dd")
10 |
11 | @SuppressLint("SimpleDateFormat")
12 | private val sdfDetail = SimpleDateFormat("yyy-MM-dd HH:mm")
13 |
14 | /**
15 | * 将时间戳转换为日期
16 | *
17 | * @param detail 是否显示详细时间 (时分秒)
18 | * @return 格式化后的日期
19 | */
20 | fun Long.format(
21 | detail: Boolean = false
22 | ): String {
23 | return if (detail) {
24 | sdfDetail.format(Date(this))
25 | } else {
26 | sdf.format(Date(this))
27 | }
28 | }
29 |
30 | /**
31 | * 将时间戳转换为时间, 格式为 00:00
32 | *
33 | * @param duration 时间长度(毫秒)
34 | * @return 格式化后的时间
35 | */
36 | fun prettyDuration(duration: Long): String {
37 | val seconds = duration / 1000
38 | val minutes = seconds / 60
39 | val hours = minutes / 60
40 | return when {
41 | hours > 0 -> "${hours.toString().padStart(2, '0')}:${
42 | (minutes % 60).toString().padStart(2, '0')
43 | }:${(seconds % 60).toString().padStart(2, '0')}"
44 | else -> "${minutes.toString().padStart(2, '0')}:${
45 | (seconds % 60).toString().padStart(2, '0')
46 | }"
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/VideoCache.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util
2 |
3 | import android.content.Context
4 | import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
5 | import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
6 | import com.google.android.exoplayer2.upstream.DataSource
7 | import com.google.android.exoplayer2.upstream.cache.CacheDataSource
8 | import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
9 | import com.google.android.exoplayer2.upstream.cache.SimpleCache
10 | import com.rerere.iwara4a.util.okhttp.SmartDns
11 | import com.rerere.iwara4a.util.okhttp.UserAgentInterceptor
12 | import okhttp3.OkHttpClient
13 | import java.io.File
14 |
15 | object VideoCache {
16 | private const val MAX_SIZE = 1024 * 1024 * 512L // 512 M
17 |
18 | private var videoCache: DataSource.Factory? = null
19 |
20 | fun getCache(context: Context): DataSource.Factory {
21 | if (videoCache == null) {
22 | val directory = File(context.cacheDir, "exo")
23 | val cache = SimpleCache(
24 | directory,
25 | LeastRecentlyUsedCacheEvictor(MAX_SIZE), // LRU 缓存算法
26 | StandaloneDatabaseProvider(context)
27 | )
28 | videoCache = CacheDataSource.Factory()
29 | .setCache(cache)
30 | .setUpstreamDataSourceFactory(OkHttpDataSource.Factory(
31 | OkHttpClient.Builder()
32 | .dns(SmartDns)
33 | .retryOnConnectionFailure(true)
34 | .addInterceptor(UserAgentInterceptor())
35 | .build()
36 | ))
37 | .setCacheKeyFactory {
38 | it.key ?: it.uri.toString()
39 | .substringAfter("file=")
40 | .substringBefore("&")
41 | }
42 | }
43 | return videoCache!!
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/okhttp/CookieJarHelper.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util.okhttp
2 |
3 | import com.rerere.iwara4a.data.model.session.Session
4 | import okhttp3.Cookie
5 | import okhttp3.CookieJar
6 | import okhttp3.HttpUrl
7 | import okhttp3.OkHttpClient
8 |
9 | private val HAS_JS = Cookie.Builder()
10 | .name("has_js")
11 | .value("1")
12 | .domain("ecchi.iwara.tv")
13 | .build()
14 |
15 | class CookieJarHelper : CookieJar, Iterable {
16 | private var cookies = ArrayList()
17 |
18 | override fun loadForRequest(url: HttpUrl): List {
19 | return if(url.host.contains("iwara.tv")) {
20 | cookies
21 | } else {
22 | emptyList()
23 | }
24 | }
25 |
26 | override fun saveFromResponse(url: HttpUrl, cookies: List) {
27 | if(!url.host.contains("iwara.tv")) {
28 | return
29 | }
30 | this.cookies = ArrayList(cookies)
31 | }
32 |
33 | override fun iterator(): Iterator = cookies.iterator()
34 |
35 | fun clean() = cookies.clear()
36 |
37 | fun init(session: Session) {
38 | clean()
39 | if (session.isNotEmpty()) {
40 | cookies.add(session.toCookie())
41 | cookies.add(HAS_JS)
42 | } else {
43 | println("### NOT LOGIN ###")
44 | }
45 | }
46 | }
47 |
48 | fun OkHttpClient.getCookie() = this.cookieJar as CookieJarHelper
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/okhttp/HtmlFormatUtil.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util.okhttp
2 |
3 | import org.jsoup.internal.StringUtil
4 | import org.jsoup.nodes.Element
5 | import org.jsoup.nodes.Node
6 | import org.jsoup.nodes.TextNode
7 | import org.jsoup.select.NodeTraversor
8 | import org.jsoup.select.NodeVisitor
9 |
10 | fun Element.getPlainText(): String {
11 | val formatter = HtmlFormatUtil.FormattingVisitor()
12 | NodeTraversor.traverse(
13 | formatter,
14 | this
15 | ) // walk the DOM, and call .head() and .tail() for each node
16 | return formatter.toString().trim()
17 | }
18 |
19 | object HtmlFormatUtil {
20 | // the formatting rules, implemented in a breadth-first DOM traverse
21 | class FormattingVisitor : NodeVisitor {
22 | private var width = 0
23 | private val accum = StringBuilder() // holds the accumulated text
24 |
25 | // hit when the node is first seen
26 | override fun head(node: Node, depth: Int) {
27 | val name: String = node.nodeName()
28 | if (node is TextNode) append((node as TextNode).text()) // TextNodes carry all user-readable text in the DOM.
29 | else if (name == "li") append("\n * ") else if (name == "dt") append(" ") else if (StringUtil.`in`(
30 | name,
31 | "p",
32 | "h1",
33 | "h2",
34 | "h3",
35 | "h4",
36 | "h5",
37 | "tr"
38 | )
39 | ) append("\n")
40 | }
41 |
42 | // hit when all of the node's children (if any) have been visited
43 | override fun tail(node: Node, depth: Int) {
44 | val name: String = node.nodeName()
45 | if (StringUtil.`in`(
46 | name,
47 | "br",
48 | "dd",
49 | "dt",
50 | "p",
51 | "h1",
52 | "h2",
53 | "h3",
54 | "h4",
55 | "h5"
56 | )
57 | ) append("\n") /*else if (name == "a") append(
58 | java.lang.String.format(
59 | " <%s>",
60 | node.absUrl("href")
61 | )
62 | )*/
63 | }
64 |
65 | // appends text to the string builder with a simple word wrap method
66 | private fun append(text: String) {
67 | if (text.startsWith("\n")) width =
68 | 0 // reset counter if starts with a newline. only from formats above, not in natural text
69 | if (text == " " &&
70 | (accum.isEmpty() || StringUtil.`in`(accum.substring(accum.length - 1), " ", "\n"))
71 | ) return // don't accumulate long runs of empty spaces
72 | if (text.length + width > maxWidth) { // won't fit, needs to wrap
73 | val words = text.split("\\s+").toTypedArray()
74 | for (i in words.indices) {
75 | var word = words[i]
76 | val last = i == words.size - 1
77 | if (!last) // insert a space if not the last word
78 | word = "$word "
79 | if (word.length + width > maxWidth) { // wrap and reset counter
80 | accum.append("\n").append(word)
81 | width = word.length
82 | } else {
83 | accum.append(word)
84 | width += word.length
85 | }
86 | }
87 | } else { // fits as is, without need to wrap text
88 | accum.append(text)
89 | width += text.length
90 | }
91 | }
92 |
93 | override fun toString(): String {
94 | return accum.toString()
95 | }
96 |
97 | companion object {
98 | private const val maxWidth = 80
99 | }
100 | }
101 | }
--------------------------------------------------------------------------------
/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: Throwable) {
28 | // ignore
29 | }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/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 | import java.net.SocketTimeoutException
7 |
8 | class Retry(
9 | private val maxRetryTimes: Int = 2
10 | ) : Interceptor {
11 | override fun intercept(chain: Interceptor.Chain): Response {
12 | val request = chain.request()
13 | val newRequest = {
14 | request.newBuilder().build()
15 | }
16 |
17 | repeat(maxRetryTimes) {
18 | kotlin.runCatching {
19 | chain.proceed(newRequest())
20 | }.onSuccess {
21 | return it
22 | }.onFailure {
23 | if(it is SocketTimeoutException){
24 | Log.d("Retry", "Retry: ${it.message}")
25 | Log.d("Retry", "intercept: ${request.url}")
26 | } else {
27 | throw it
28 | }
29 | }
30 | }
31 | return chain.proceed(newRequest())
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/rerere/iwara4a/util/okhttp/SmartDns.kt:
--------------------------------------------------------------------------------
1 | package com.rerere.iwara4a.util.okhttp
2 |
3 | import me.rerere.compose_setting.preference.mmkvPreference
4 | import okhttp3.Dns
5 | import okhttp3.HttpUrl.Companion.toHttpUrl
6 | import okhttp3.OkHttpClient
7 | import okhttp3.dnsoverhttps.DnsOverHttps
8 | import java.net.InetAddress
9 | import java.util.concurrent.TimeUnit
10 |
11 | object SmartDns : Dns {
12 | private const val CLOUDFLARE_DOH = "https://1.0.0.1/dns-query"
13 |
14 | private var dnsClient: DnsOverHttps = DnsOverHttps.Builder()
15 | .client(
16 | OkHttpClient.Builder()
17 | .connectTimeout(10_000, TimeUnit.MILLISECONDS)
18 | .build()
19 | )
20 | .url(
21 | mmkvPreference.getString("setting.doh_url", CLOUDFLARE_DOH)!!.toHttpUrl()
22 | )
23 | .build()
24 |
25 | override fun lookup(hostname: String): List {
26 | return if (mmkvPreference.getBoolean("setting.useDoH", false)) {
27 | dnsClient.lookup(hostname)
28 | } else {
29 | Dns.SYSTEM.lookup(hostname)
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/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 | const val USER_AGENT =
8 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36"
9 |
10 | class UserAgentInterceptor(private val userAgent: String = USER_AGENT) : Interceptor {
11 | override fun intercept(chain: Interceptor.Chain): Response {
12 | val userAgentRequest: Request = chain.request()
13 | .newBuilder()
14 | .header("User-Agent", userAgent)
15 | .build()
16 | return chain.proceed(userAgentRequest)
17 | }
18 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/anime_1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/anime_1.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/anime_2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/anime_2.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/anime_3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/anime_3.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/anime_4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/anime_4.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/compose_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/compose_logo.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/download.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/download.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/failed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/failed.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/image_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/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/logo.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/miku.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/miku.gif
--------------------------------------------------------------------------------
/app/src/main/res/drawable/outline_discord_20.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/outline_discord_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/drawable/placeholder.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/play_icon.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/upzhu.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
11 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/video_icon.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/view_icon.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ducky.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ducky_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ducky.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-hdpi/ducky.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ducky_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-hdpi/ducky_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ducky.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-mdpi/ducky.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ducky_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-mdpi/ducky_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ducky.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-xhdpi/ducky.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ducky_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-xhdpi/ducky_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ducky.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-xxhdpi/ducky.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ducky_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-xxhdpi/ducky_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ducky.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-xxxhdpi/ducky.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ducky_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/app/src/main/res/mipmap-xxxhdpi/ducky_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 | #39C5BB
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ducky_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #3DDC84
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/xml-v25/shortcut.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
12 |
13 |
17 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | quasar.ac
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/provider_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/art/doc/README_EN.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 |
8 | A android application for iwara.tv, based on Jetpack Compose and some other modern Android develop tech stack
9 |
10 | ## ⬇ Download
11 | https://github.com/jiangdashao/iwara4a/releases/latest
12 |
13 | ## Screenshot
14 | The screenshot maybe outdated
15 | | Index | Player | Search |
16 | | ----- | ------| ------|
17 | |
|
|
18 |
19 | ## 🚩 特性
20 | * Material You Design!
21 | * Login
22 | * View Videos/Images List
23 | * Play videos
24 | * View Images
25 | * View comments
26 | * Like
27 | * Subscribe
28 | * Comment
29 | * Share
30 | * Search
31 | * Sort
32 | * History
33 | * Download
34 | * ...
35 |
36 | ## 🎨 Tech Stack
37 | * MVVM
38 | * Single Activity + Navigation
39 | * Jetpack Compose
40 | * Kotlin Coroutine
41 | * Okhttp + Jsoup
42 | * Retrofit
43 | * Hilt
44 | * Paging3
45 | * Navigation
--------------------------------------------------------------------------------
/art/index.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/art/index.png
--------------------------------------------------------------------------------
/art/play.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/art/play.png
--------------------------------------------------------------------------------
/art/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/art/search.png
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | buildscript {
3 | ext {
4 | // 核心版本
5 | kotlin_version = '1.7.10'
6 | compose_version = '1.3.0-beta02'
7 | compiler_version = '1.3.1'
8 |
9 | // 依赖版本
10 | hilt_version = '2.43.2'
11 | md3_version = '1.0.0-beta02'
12 | okhttp_version = '5.0.0-alpha.10'
13 | exoplayer_version = '2.18.1'
14 | lottie_version = '5.2.0'
15 | paging_version = "3.1.1"
16 | acc_version = '0.26.3-beta'
17 | retrofit_version = '2.9.0'
18 | room_version = "2.5.0-alpha03"
19 | coil_version = "2.2.1"
20 | }
21 | }
22 |
23 | plugins {
24 | id 'com.android.application' version '7.3.0-rc01' apply false
25 | id 'com.android.library' version '7.3.0-rc01' apply false
26 | id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
27 | id 'com.google.dagger.hilt.android' version "$hilt_version" apply false
28 | id 'com.google.devtools.ksp' version '1.7.10-1.0.6' apply false
29 | id 'com.android.test' version '7.3.0-rc01' apply false
30 | id 'org.jetbrains.kotlin.jvm' version "$kotlin_version" apply false
31 | }
32 |
33 | task clean(type: Delete) {
34 | delete rootProject.buildDir
35 | }
--------------------------------------------------------------------------------
/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=-Xmx4096m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -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
22 |
23 | # Enable Configure on demand
24 | org.gradle.configureondemand=true
25 | # Enable parallel builds
26 | org.gradle.parallel=true
27 | # Enable simple gradle caching
28 | org.gradle.caching=true
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RockySteveJobs/iwara4a-1/fd6ebb8817269f559669286897ce019fd4375f37/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed Feb 02 15:48:43 CST 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/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 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | // maven { url 'https://jitpack.io' }
7 | }
8 | }
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | maven { url 'https://jitpack.io' }
15 | maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
16 | //jcenter()
17 | }
18 | }
19 | rootProject.name = "iwara4a"
20 | include ':app'
21 |
--------------------------------------------------------------------------------