├── .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 | ![Logo](/app/src/main/res/mipmap-xxhdpi/ducky.png) 2 | # Iwara4A 3 | [![GitHub issues](https://img.shields.io/github/issues/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a/issues) 4 | [![GitHub forks](https://img.shields.io/github/forks/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a/network) 5 | [![GitHub stars](https://img.shields.io/github/stars/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a/stargazers) 6 | [![GitHub license](https://img.shields.io/github/license/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a) 7 | ![GitHub all releases](https://img.shields.io/github/downloads/re-ovo/iwara4a/total) 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 | 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 | [![GitHub issues](https://img.shields.io/github/issues/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a/issues) 3 | [![GitHub forks](https://img.shields.io/github/forks/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a/network) 4 | [![GitHub stars](https://img.shields.io/github/stars/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a/stargazers) 5 | [![GitHub license](https://img.shields.io/github/license/re-ovo/iwara4a)](https://github.com/jiangdashao/iwara4a) 6 | ![GitHub all releases](https://img.shields.io/github/downloads/re-ovo/iwara4a/total) 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 | --------------------------------------------------------------------------------