diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..bdf9bc1 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +AnimePlayer UA \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..71bb6bd --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..0897082 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..6806f5a --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,53 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f9c8924 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,93 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) + + id("com.google.devtools.ksp") version "2.0.21-1.0.25" + id("kotlin-parcelize") + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" +} + +android { + namespace = "com.crocoby.animeplayerua" + compileSdk = 35 + + defaultConfig { + applicationId = "com.crocoby.animeplayerua" + minSdk = 28 + targetSdk = 34 + versionCode = 2 + versionName = "0.1.1" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(libs.androidx.navigation.runtime.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.kamel.image.default) + implementation(libs.accompanist.systemuicontroller) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.kotlinx.serialization.json) + implementation(libs.ksoup.lite) + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.dash) + implementation(libs.androidx.media3.exoplayer.hls) + implementation(libs.androidx.media3.ui) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + annotationProcessor(libs.androidx.room.compiler) + + ksp(libs.androidx.room.compiler) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/crocoby/animeplayerua/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/crocoby/animeplayerua/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..c7ebce5 --- /dev/null +++ b/app/src/androidTest/java/com/crocoby/animeplayerua/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.crocoby.animeplayerua + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.crocoby.animeplayerua", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..937e635 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/App.kt b/app/src/main/java/com/crocoby/animeplayerua/App.kt new file mode 100644 index 0000000..51f98be --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/App.kt @@ -0,0 +1,59 @@ +package com.crocoby.animeplayerua + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.crocoby.animeplayerua.activities.HomeActivity +import com.crocoby.animeplayerua.activities.InfoActivity +import com.crocoby.animeplayerua.activities.PlaylistsActivity +import com.crocoby.animeplayerua.activities.SearchActivity +import com.crocoby.animeplayerua.activities.VideoActivity +import com.crocoby.animeplayerua.logic.AnimeDao +import com.crocoby.animeplayerua.logic.AppDatabase + +var database: AnimeDao? = null +@SuppressLint("StaticFieldLeak") +var navController: NavHostController? = null + +@Composable +fun App(context: Context) { + navController = rememberNavController() + database = remember { AppDatabase.getDatabase(context).getDao() } + + MaterialTheme(colorScheme = darkScheme) { + NavHost( + navController!!, + startDestination = Routes.HOME, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + builder = { + composable(Routes.HOME) { + HomeActivity() + } + composable(Routes.PLAYLISTS) { + PlaylistsActivity() + } + composable(Routes.SEARCH + "/{query}") { + val query = it.arguments?.getString("query") ?: "Steins;Gate" + SearchActivity(query) + } + composable(Routes.ANIMEINFO + "/{slug}") { + val slug = it.arguments?.getString("slug") ?: "1886-shteynova-brama-steinsgate-steins-gate" + InfoActivity(slug) + } + composable(Routes.VIDEO + "/{url}") { + val iframeUrl = it.arguments?.getString("url")!! + VideoActivity(iframeUrl) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/MainActivity.kt b/app/src/main/java/com/crocoby/animeplayerua/MainActivity.kt new file mode 100644 index 0000000..871c10d --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/MainActivity.kt @@ -0,0 +1,26 @@ +package com.crocoby.animeplayerua + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.crocoby.animeplayerua.activities.HomeActivity + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + setContent { + App(this) + } + } +} + +@Preview +@Composable +fun Preview() { + HomeActivity() +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/Models.kt b/app/src/main/java/com/crocoby/animeplayerua/Models.kt new file mode 100644 index 0000000..715e5f8 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/Models.kt @@ -0,0 +1,162 @@ +package com.crocoby.animeplayerua + +import android.os.Parcel +import android.os.Parcelable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.util.fastJoinToString +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.crocoby.animeplayerua.utils.UrlEncoderUtil +import kotlinx.parcelize.Parceler +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.time.Instant + +@Parcelize +@Serializable +data class AnimeItem( + val slug: String, + val name: String, + val imageUrl: String +) : Parcelable { + fun toAnimeInfo( + description: String, + rate: Int, + playlists: Map, + episodes: List + ): AnimeInfo { + return AnimeInfo(slug, name, imageUrl, description, rate, playlists, episodes) + } + + companion object : Parceler { + override fun AnimeItem.write(parcel: Parcel, flags: Int) { + val json = Json.encodeToString(this) + parcel.writeString(json) + } + + override fun create(parcel: Parcel): AnimeItem { + val string = parcel.readString()!! + val json = Json.decodeFromString(string) + return json + } + } +} + +@Parcelize +@Serializable +data class AnimeEpisode( + val name: String, + val url: String, + val playlistsId: String +) : Parcelable { + companion object : Parceler { + override fun AnimeEpisode.write(parcel: Parcel, flags: Int) { + val json = Json.encodeToString(this) + parcel.writeString(json) + } + + override fun create(parcel: Parcel): AnimeEpisode { + val string = parcel.readString()!! + val json = Json.decodeFromString(string) + return json + } + } +} + +@Parcelize +@Serializable +data class AnimeInfo( + val slug: String, + val name: String, + val imageUrl: String, + val description: String, + val rate: Int, + val playlists: Map, + val episodes: List, +) : Parcelable { + fun toAnimeItem(): AnimeItem { + return AnimeItem(this.slug, this.name, this.imageUrl) + } + + companion object : Parceler { + override fun AnimeInfo.write(parcel: Parcel, flags: Int) { + val json = Json.encodeToString(this) + parcel.writeString(json) + } + + override fun create(parcel: Parcel): AnimeInfo { + val string = parcel.readString()!! + val json = Json.decodeFromString(string) + return json + } + } +} + +object Routes { + const val HOME = "home" + const val PLAYLISTS = "playlists" + const val SEARCH = "search" + const val ANIMEINFO = "anime" + const val VIDEO = "video" + + fun paramsConcat(route: String, vararg param: String): String { + val trimmed = route.trim('/') + val encoded: List = param.map { + UrlEncoderUtil.encode(it) + } + val joined = encoded.fastJoinToString("/") + val res = "$trimmed/$joined" + + return res + } + + fun clearParams(path: String): String { + val trimmed = path.trimStart('/') + val split = trimmed.split('/', limit = 2) + return split[0] + } +} + +data class MenuItem( + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val title: String, + val routes: List +) + +// Rooms +@Parcelize +@Serializable +@Entity(tableName = "animes") +data class AnimeDBEntity( + @PrimaryKey val slug: String = "", + var watchedMark: Boolean = false, + var watchedTime: Long = Instant.now().toEpochMilli(), + var likedMark: Boolean = false, + var likedTime: Long = Instant.now().toEpochMilli(), + var lastWatchedEpisode: String = "", + var lastWatchedTime: Long = Instant.now().toEpochMilli(), + var name: String = "", + var imageUrl: String = "" +) : Parcelable { + fun toAnimeItem(): AnimeItem { + return AnimeItem( + slug, name, imageUrl + ) + } + + companion object : Parceler { + override fun AnimeDBEntity.write(parcel: Parcel, flags: Int) { + val json = Json.encodeToString(this) + parcel.writeString(json) + } + + override fun create(parcel: Parcel): AnimeDBEntity { + val string = parcel.readString()!! + val json = Json.decodeFromString(string) + return json + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/UiPrefs.kt b/app/src/main/java/com/crocoby/animeplayerua/UiPrefs.kt new file mode 100644 index 0000000..0b9e463 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/UiPrefs.kt @@ -0,0 +1,37 @@ +package com.crocoby.animeplayerua + +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +object UiColors { + val text = Color(0xFFFFFFFF) + val error = Color(0xFFBA1A1A) + val buttons = Color(0xFF8104F4) + val greyBar = Color(0xFF323232) + val background = Color(0xFF161616) + val yellow = Color(0xFFFACC15) +} + +object UiConstants { + val horizontalScreenPadding = 24.dp + val verticalScreenPadding = 32.dp +} + +val darkScheme = darkColorScheme( + primary = UiColors.buttons, + secondary = UiColors.greyBar, + surface = UiColors.greyBar, + background = UiColors.background, + + onSurface = UiColors.text, + onBackground = UiColors.text, + onPrimary = UiColors.text, + onSecondary = UiColors.text, + onError = UiColors.error +) + +@Composable +fun CardDefaults.zeroCardElevation() = cardElevation(0.dp, 0.dp, 0.dp, 0.dp, 0.dp, 0.dp) \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/activities/Home.kt b/app/src/main/java/com/crocoby/animeplayerua/activities/Home.kt new file mode 100644 index 0000000..4cdde05 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/activities/Home.kt @@ -0,0 +1,78 @@ +package com.crocoby.animeplayerua.activities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crocoby.animeplayerua.AnimeItem +import com.crocoby.animeplayerua.Routes +import com.crocoby.animeplayerua.logic.runParser +import com.crocoby.animeplayerua.navController +import com.crocoby.animeplayerua.widgets.AnimeCategory +import com.crocoby.animeplayerua.widgets.AnimeCategoryLoading +import com.crocoby.animeplayerua.widgets.ApplicationScaffold +import com.crocoby.animeplayerua.widgets.HorizontalPadding +import com.crocoby.animeplayerua.widgets.SearchField +import com.crocoby.animeplayerua.widgets.TopPadding + +@Composable +fun HomeActivity() { + var loaded by rememberSaveable { mutableStateOf(false) } + val animeNew = rememberSaveable { mutableListOf() } + val animeSeasonBest = rememberSaveable { mutableListOf() } + + runParser( + function = { + if (!loaded) { + val mainPage = getAnimeMainPage() + animeNew.addAll(mainPage.new) + animeSeasonBest.addAll(mainPage.bestSeason) + loaded = true + } + }, + onError = { + }, + ) + + ApplicationScaffold() { + TopPadding { + Column(Modifier.fillMaxSize()) { + HorizontalPadding { + SearchField { + if (it.isNotEmpty()) { + navController!!.navigate(Routes.paramsConcat(Routes.SEARCH, it)) + } + } + } + Spacer(Modifier.height(24.dp)) + Column( + Modifier + .verticalScroll(rememberScrollState(0)) + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + if (loaded) { + if (animeNew.isNotEmpty()) AnimeCategory("Новинки", animeNew) { openInfoActivity(it) } + if (animeSeasonBest.isNotEmpty()) AnimeCategory("Найкраще за сезон", animeSeasonBest) { openInfoActivity(it) } + } else { + AnimeCategoryLoading(20) + AnimeCategoryLoading(20) + } + Spacer(Modifier.height(24.dp)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/activities/Info.kt b/app/src/main/java/com/crocoby/animeplayerua/activities/Info.kt new file mode 100644 index 0000000..9974540 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/activities/Info.kt @@ -0,0 +1,422 @@ +package com.crocoby.animeplayerua.activities + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.crocoby.animeplayerua.AnimeDBEntity +import com.crocoby.animeplayerua.AnimeInfo +import com.crocoby.animeplayerua.AnimeItem +import com.crocoby.animeplayerua.R +import com.crocoby.animeplayerua.Routes +import com.crocoby.animeplayerua.UiColors +import com.crocoby.animeplayerua.UiConstants +import com.crocoby.animeplayerua.database +import com.crocoby.animeplayerua.logic.runParser +import com.crocoby.animeplayerua.navController +import com.crocoby.animeplayerua.utils.focusBorder +import com.crocoby.animeplayerua.widgets.AnimePlaylistRow +import com.crocoby.animeplayerua.widgets.EpisodeButton +import com.crocoby.animeplayerua.widgets.HorizontalPadding +import com.crocoby.animeplayerua.widgets.TopPadding +import io.kamel.image.KamelImage +import io.kamel.image.asyncPainterResource +import kotlinx.coroutines.runBlocking +import java.time.Instant + +@Composable +fun InfoActivity(animeSlug: String) { + var animeInfo by remember { mutableStateOf(AnimeInfo(animeSlug, "", "", "", 0, mapOf(), listOf())) } + var dbEntity by remember { mutableStateOf(AnimeDBEntity()) } + var loaded by rememberSaveable { mutableStateOf(false) } + + runParser( + function = { + if (!loaded) { + animeInfo = getAnimeInfoBySlug(animeSlug) + dbEntity = database!!.getBySlug(animeSlug)?:AnimeDBEntity( + slug = animeInfo.slug, + name = animeInfo.name, + imageUrl = animeInfo.imageUrl + ) + + loaded = true + } + }, + onError = { + throw it + } + ) + + if (loaded) + InfoLoaded(animeInfo, dbEntity) + else + InfoLoading() +} + +@Composable +fun InfoLoaded( + animeInfo: AnimeInfo, + dbEntity: AnimeDBEntity +) { + val slug = rememberSaveable { animeInfo.slug } + val name = rememberSaveable { animeInfo.name } + val imageUrl = rememberSaveable { animeInfo.imageUrl } + val description = rememberSaveable { animeInfo.description } + val rate = rememberSaveable { animeInfo.rate } + val playlists = rememberSaveable { animeInfo.playlists } + val episodes = rememberSaveable { animeInfo.episodes } + + var liked by rememberSaveable { mutableStateOf(dbEntity.likedMark) } + var likedTime by rememberSaveable { mutableLongStateOf(dbEntity.likedTime) } + var watched by rememberSaveable { mutableStateOf(dbEntity.watchedMark) } + var watchedTime by rememberSaveable { mutableLongStateOf(dbEntity.watchedTime) } + var lastWatchedEpisode by rememberSaveable { mutableStateOf(dbEntity.lastWatchedEpisode) } + var lastWatchedTime by rememberSaveable { mutableLongStateOf(dbEntity.lastWatchedTime) } + + val keys = playlists.keys.map { + it.split("_") + } + val rows = (keys.maxOfOrNull { + it.count() + }?:1) - 1 + + var foldDescription by rememberSaveable { mutableStateOf(true) } + val currentPlaylist = remember> { + if (lastWatchedEpisode.isNotEmpty() && lastWatchedEpisode.split("_").count() == rows + 2 && playlists.keys.contains(lastWatchedEpisode.split("_").subList(0, rows + 1).joinToString("_"))) { + lastWatchedEpisode.split("_").subList(0, rows + 1).map{it.toInt()} + } else { + (0..rows).map { 0 } + }.toMutableStateList() + } + + val joinedCurrentPlaylist = currentPlaylist.joinToString("_") + val activeEpisodes = episodes.filter { + it.playlistsId == joinedCurrentPlaylist + } + + val editableAnimeDBEntity = AnimeDBEntity( + slug, watched, watchedTime, liked, likedTime, lastWatchedEpisode, lastWatchedTime, name, imageUrl + ) + + LaunchedEffect(editableAnimeDBEntity) { + runBlocking { + database!!.insert(editableAnimeDBEntity) + } + } + + Scaffold { innerPadding -> + Box(Modifier.padding(innerPadding)) { + TopPadding { + Column(Modifier.fillMaxSize()) { + HorizontalPadding { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton( + onClick = { + navController!!.navigateUp() + } + ) { + Icon( + modifier = Modifier.width(24.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "backIcon" + ) + } + Spacer(Modifier.width(8.dp)) + Text( + text = name, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + fontSize = 24.sp, + ) + ) + } + } + Spacer(Modifier.height(UiConstants.verticalScreenPadding)) + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState(0)) + ) { + HorizontalPadding { + Column { + Box(Modifier.fillMaxWidth().height(210.dp)) { + KamelImage( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)), + resource = { asyncPainterResource(data = imageUrl) }, + contentDescription = "animeBanner", + contentScale = ContentScale.Crop + ) + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton( + modifier = Modifier.focusBorder(shape = CircleShape), + onClick = { + liked = !liked + likedTime = Instant.now().toEpochMilli() + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color(0x22FFFFFF) + ) + ) { + Icon( + modifier = Modifier.width(24.dp), + imageVector = Icons.Filled.Favorite, + contentDescription = "heartIcon", + tint = if (liked) UiColors.error else UiColors.background + ) + } + IconButton( + modifier = Modifier.focusBorder(shape = CircleShape), + onClick = { + watched = !watched + watchedTime = Instant.now().toEpochMilli() + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color(0x22FFFFFF) + ) + ) { + if (watched) + Icon( + modifier = Modifier.width(24.dp), + painter = painterResource(R.drawable.baseline_done_all), + contentDescription = "markIcon", + tint = Color.White + ) + else + Icon( + modifier = Modifier.width(24.dp), + imageVector = Icons.Filled.Done, + contentDescription = "markIcon", + tint = UiColors.background + ) + } + } + } + Spacer(Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "★".repeat(rate) + "☆".repeat(10 - rate), + style = TextStyle( + color = UiColors.yellow, + fontSize = 20.sp, + ) + ) + Text( + text = "(${rate}/10)", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + ) + } + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier + .fillMaxWidth() + .clickable { + foldDescription = !foldDescription + }, + text = description, + maxLines = if (foldDescription) 5 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + fontSize = 16.sp + ) + ) + Spacer(Modifier.height(16.dp)) + Text( + "Серії", + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + ) + } + } + Spacer(Modifier.height(8.dp)) + Column { + for (row in 1..rows) { + val str = currentPlaylist.subList(0, row).joinToString("_") + "_" + val names = playlists.filter { (k, v) -> + k.split("_").count() == row + 1 && k.startsWith(str) + }.values + AnimePlaylistRow( + names.toList(), + currentPlaylist[row] + ) { + currentPlaylist[row] = it + for (nr in (row + 1)..rows) { + currentPlaylist[nr] = 0 + } + } + } + } + Spacer(Modifier.height(8.dp)) + HorizontalPadding { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (lastWatchedEpisode.isNotEmpty() && activeEpisodes.any { lastWatchedEpisode.startsWith(it.playlistsId) }) { + val ep = activeEpisodes[lastWatchedEpisode.split("_").last().toInt()] + EpisodeButton(ep.name, continueWatching = true) { + lastWatchedTime = Instant.now().toEpochMilli() + + navController!!.navigate(Routes.paramsConcat(Routes.VIDEO, ep.url)) + } + } + for ((index, episode) in activeEpisodes.withIndex()) { + EpisodeButton(episode.name) { + lastWatchedEpisode = "${episode.playlistsId}_$index" + lastWatchedTime = Instant.now().toEpochMilli() + + navController!!.navigate(Routes.paramsConcat(Routes.VIDEO, episode.url)) + } + } + } + } + Spacer(Modifier.height(UiConstants.verticalScreenPadding)) + } + } + } + } + } +} + +@Composable +fun InfoLoading() { + Scaffold { innerPadding -> + Box(Modifier.padding(innerPadding)) { + TopPadding { + HorizontalPadding { + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + Modifier + .clip(RoundedCornerShape(100)) + .width(48.dp) + .height(48.dp) + .background(UiColors.greyBar), + ) {} + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .height(48.dp) + .background(UiColors.greyBar), + ) {} + } + Spacer(Modifier.height(UiConstants.verticalScreenPadding)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .height(210.dp) + .background(UiColors.greyBar), + ) {} + Spacer(Modifier.height(16.dp)) + Box( + Modifier + .clip(RoundedCornerShape(24.dp)) + .width(220.dp) + .height(32.dp) + .background(UiColors.greyBar), + ) {} + Spacer(Modifier.height(8.dp)) + Box( + Modifier + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .height(150.dp) + .background(UiColors.greyBar), + ) {} + Spacer(Modifier.height(16.dp)) + Box( + Modifier + .clip(RoundedCornerShape(24.dp)) + .width(100.dp) + .height(32.dp) + .background(UiColors.greyBar), + ) {} + Spacer(Modifier.height(8.dp)) + Box( + Modifier + .clip(RoundedCornerShape(24.dp)) + .background(UiColors.greyBar) + .fillMaxWidth() + .weight(1f), + ) {} + Spacer(Modifier.height(UiConstants.verticalScreenPadding)) + } + } + } + } + } +} + +fun openInfoActivity(item: AnimeItem) { + navController!!.navigate(Routes.paramsConcat(Routes.ANIMEINFO, item.slug)) { + restoreState = true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/activities/Playlists.kt b/app/src/main/java/com/crocoby/animeplayerua/activities/Playlists.kt new file mode 100644 index 0000000..89c73b8 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/activities/Playlists.kt @@ -0,0 +1,82 @@ +package com.crocoby.animeplayerua.activities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crocoby.animeplayerua.AnimeItem +import com.crocoby.animeplayerua.database +import com.crocoby.animeplayerua.logic.runParser +import com.crocoby.animeplayerua.widgets.AnimeCategory +import com.crocoby.animeplayerua.widgets.AnimeCategoryLoading +import com.crocoby.animeplayerua.widgets.ApplicationScaffold +import com.crocoby.animeplayerua.widgets.TextBanner + +@Composable +fun PlaylistsActivity() { + val animeContinueWatching = remember { mutableListOf() } + val animeLiked = remember { mutableListOf() } + val animeWatched = remember { mutableListOf() } + var loaded by remember { mutableStateOf(false) } + + runParser( + function = { + if (!loaded) { + animeContinueWatching.addAll(database!!.getEpisodeWatched().map {it.toAnimeItem()}) + animeLiked.addAll(database!!.getLiked().map {it.toAnimeItem()}) + animeWatched.addAll(database!!.getWatched().map {it.toAnimeItem()}) + + loaded = true + } + }, + onError = {} + ) + + ApplicationScaffold() { + if (animeContinueWatching.isEmpty() && animeLiked.isEmpty() && animeWatched.isEmpty() && loaded) { + TextBanner("Тут поки-що нічого немає ;)") + } else { + Column(Modifier.fillMaxSize()) { + Column( + Modifier + .verticalScroll(rememberScrollState(0)) + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + Spacer(Modifier) + if (loaded) { + if (animeContinueWatching.isNotEmpty()) AnimeCategory( + "Продовжити перегляд", + animeContinueWatching + ) { openInfoActivity(it) } + if (animeLiked.isNotEmpty()) AnimeCategory( + "Сподобалося", + animeLiked + ) { openInfoActivity(it) } + if (animeWatched.isNotEmpty()) AnimeCategory( + "Переглянуто", + animeWatched + ) { openInfoActivity(it) } + } else { + AnimeCategoryLoading(20) + AnimeCategoryLoading(20) + AnimeCategoryLoading(20) + } + Spacer(Modifier.height(24.dp)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/activities/Search.kt b/app/src/main/java/com/crocoby/animeplayerua/activities/Search.kt new file mode 100644 index 0000000..82b62b3 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/activities/Search.kt @@ -0,0 +1,65 @@ +package com.crocoby.animeplayerua.activities + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crocoby.animeplayerua.AnimeItem +import com.crocoby.animeplayerua.Routes +import com.crocoby.animeplayerua.logic.runParser +import com.crocoby.animeplayerua.navController +import com.crocoby.animeplayerua.widgets.AnimeCategory +import com.crocoby.animeplayerua.widgets.AnimeCategoryLoading +import com.crocoby.animeplayerua.widgets.ApplicationScaffold +import com.crocoby.animeplayerua.widgets.HorizontalPadding +import com.crocoby.animeplayerua.widgets.SearchField +import com.crocoby.animeplayerua.widgets.TextBanner +import com.crocoby.animeplayerua.widgets.TopPadding + +@Composable +fun SearchActivity( + searchQuery: String +) { + var loaded by rememberSaveable { mutableStateOf(false) } + val found = rememberSaveable { mutableListOf() } + + runParser( + function = { + if (!loaded) { + found.addAll(searchAnimeByName(searchQuery)) + loaded = true + } + }, + onError = {} + ) + + ApplicationScaffold() { + TopPadding { + Column(Modifier.fillMaxSize()) { + HorizontalPadding { + SearchField(searchQuery) { + if (it.isNotEmpty() && searchQuery != it) { + navController!!.navigate(Routes.paramsConcat(Routes.SEARCH, it)) { + navController!!.navigateUp() + } + } + } + } + Spacer(Modifier.height(24.dp)) + if (!loaded) + AnimeCategoryLoading(20) + else if (found.isEmpty()) + TextBanner("Нічого не знайдено :(") + else + AnimeCategory("Знайдено (${found.count()}):", found) { openInfoActivity(it) } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/activities/Video.kt b/app/src/main/java/com/crocoby/animeplayerua/activities/Video.kt new file mode 100644 index 0000000..772608b --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/activities/Video.kt @@ -0,0 +1,38 @@ +package com.crocoby.animeplayerua.activities + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.crocoby.animeplayerua.logic.runParser +import com.crocoby.animeplayerua.navController +import com.crocoby.animeplayerua.widgets.VideoPlayer + +@Composable +fun VideoActivity(iframeUrl: String) { + var videoUrl by remember { mutableStateOf("") } + + runParser( + function = { + videoUrl = getDirectUrlFromIFrame(iframeUrl) + }, + onError = { + navController!!.navigateUp() + } + ) + + if (videoUrl.isEmpty()) { + Box(Modifier.fillMaxSize().background(Color.Black)) + } else { + VideoPlayer( + modifier = Modifier.fillMaxSize().background(Color.Black), + videoUrl + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/logic/Database.kt b/app/src/main/java/com/crocoby/animeplayerua/logic/Database.kt new file mode 100644 index 0000000..b2a5847 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/logic/Database.kt @@ -0,0 +1,49 @@ +package com.crocoby.animeplayerua.logic + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import com.crocoby.animeplayerua.AnimeDBEntity + +@Database(entities = [AnimeDBEntity::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun getDao(): AnimeDao + + companion object { + @Volatile + private var Instance: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + // if the Instance is not null, return it, otherwise create a new database instance. + return Instance ?: synchronized(this) { + Room.databaseBuilder(context, AppDatabase::class.java, "app") + .build() + .also { Instance = it } + } + } + } +} + + +@Dao +interface AnimeDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: AnimeDBEntity) + + @Query("SELECT * FROM animes WHERE likedMark=1 ORDER BY likedTime DESC") + suspend fun getLiked(): List + + @Query("SELECT * FROM animes WHERE watchedMark=1 ORDER BY watchedTime DESC") + suspend fun getWatched(): List + + @Query("SELECT * FROM animes WHERE lastWatchedEpisode != \"\" ORDER BY lastWatchedTime DESC") + suspend fun getEpisodeWatched(): List + + @Query("SELECT * FROM animes WHERE slug=:slug") + suspend fun getBySlug(slug: String): AnimeDBEntity? +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/logic/Parser.kt b/app/src/main/java/com/crocoby/animeplayerua/logic/Parser.kt new file mode 100644 index 0000000..b5bae6b --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/logic/Parser.kt @@ -0,0 +1,239 @@ +package com.crocoby.animeplayerua.logic + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.crocoby.animeplayerua.AnimeEpisode +import com.crocoby.animeplayerua.AnimeInfo +import com.crocoby.animeplayerua.AnimeItem +import com.crocoby.animeplayerua.utils.UrlEncoderUtil +import com.fleeksoft.ksoup.Ksoup +import io.ktor.client.HttpClient +import io.ktor.client.plugins.expectSuccess +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.math.roundToInt + +val parser = Parser() + +@Composable +fun runParser( + function: suspend Parser.() -> Unit, + onError: (ex: Exception) -> Unit +) { + LaunchedEffect(true) { + launch { + try { + function(parser) + } catch (ex: Exception) { + onError(ex) + } + } + } +} + +data class MainPageAnime(val bestSeason: List, val new: List) + +class Parser { + private val client: HttpClient = HttpClient() + private val userAgent: String = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" + + private var userHash: String = "" + + private fun newUserHash(body: String) { + for (line in body.split("\n")) { + if (line.contains("var dle_login_hash")) { + val split = line.split("'") + if (split.count() == 3) { + userHash = split[1] + } + } + } + } + + private fun parseAnimeSlugFromUrl(url: String): String { + return url.split("/").last().substringBefore(".html") + } + + suspend fun getAnimeMainPage(): MainPageAnime { + val resp = client.get("https://anitube.in.ua") { + expectSuccess = true + + header("User-Agent", userAgent) + } + val body = resp.bodyAsText() + newUserHash(body) + + val document = Ksoup.parse(body) + val content = document.body().getElementsByClass("content")[0] + + val bestSeason = ArrayList() + val new = ArrayList() + + // Best + val liElements = content.getElementsByClass("portfolio_items")[0].getElementsByTag("li") + for (liElement in liElements) { + val name = liElement.getElementsByClass("text_content")[0].text() + val img = "https://anitube.in.ua" + liElement.getElementsByTag("img")[0].attr("src") + val slug = parseAnimeSlugFromUrl(liElement.getElementsByTag("a")[0].attr("href")) + + bestSeason.add(AnimeItem(slug, name, img)) + } + + // New + val els = content.getElementsByClass("box lcol")[0].getElementsByClass("news_2") + for (el in els) { + val titleEl = el.getElementsByTag("a")[0] + + val name = titleEl.text() + val img = "https://anitube.in.ua" + el.getElementsByTag("img")[0].attr("src") + val slug = parseAnimeSlugFromUrl(titleEl.attr("href")) + + new.add(AnimeItem(slug, name, img)) + } + + return MainPageAnime(bestSeason.toList(), new.toList()) + } + + suspend fun searchAnimeByName(query: String): List { + val resp = client.post("https://anitube.in.ua/engine/ajax/controller.php?mod=search") { + expectSuccess = true + + contentType(ContentType.Application.FormUrlEncoded) + header("User-Agent", userAgent) + setBody("query=${UrlEncoderUtil.encode(query)}&user_hash=$userHash") + } + val body = resp.bodyAsText() + + val document = Ksoup.parse(body) + val content = document.body() + + val result = ArrayList() + + val els = content.getElementsByTag("a") + for (el in els) { + val name = el.getElementsByClass("searchheading_title")[0].text() + val img = el.getElementsByTag("img")[0].attr("src") + val slug = parseAnimeSlugFromUrl(el.attr("href")) + + result.add(AnimeItem(slug, name, img)) + } + + return result.toList() + } + + suspend fun getAnimeItemBySlug(slug: String): AnimeItem { + val resp = client.get("https://anitube.in.ua/$slug.html") { + expectSuccess = true + + header("User-Agent", userAgent) + } + val body = resp.bodyAsText() + newUserHash(body) + + val document = Ksoup.parse(body) + val content = document.body() + + val name = content.getElementsByTag("h2")[0].text() + val imageUrl = "https://anitube.in.ua" + content.getElementsByClass("story_post")[0].getElementsByTag("img")[0].attr("src") + + return AnimeItem(slug, name, imageUrl) + } + + suspend fun getAnimeInfoBySlug(slug: String): AnimeInfo { + val resp = client.get("https://anitube.in.ua/$slug.html") { + expectSuccess = true + + header("User-Agent", userAgent) + } + val body = resp.bodyAsText() + newUserHash(body) + + val document = Ksoup.parse(body) + val content = document.body() + + val name = content.getElementsByTag("h2")[0].text() + val imageUrl = "https://anitube.in.ua" + content.getElementsByClass("story_post")[0].getElementsByTag("img")[0].attr("src") + val description = content.getElementsByClass("my-text")[0].text() + val rate = content.getElementsByClass("div1")[0].getElementsByTag("span")[0].text().toFloat().roundToInt() + + // Parsing episodes + val newsId = slug.substringBefore('-') + val playlists = HashMap() + val episodes = ArrayList() + + if (body.contains("RalodePlayer.init(")) { + val epSplit = body.split("\n") + val line = epSplit.find { + it.contains("RalodePlayer.init(") + }!! + + val jsonStr = "[${line.substringAfter("(").substringBeforeLast(")").substringBeforeLast(",")}]" + val json = Json.parseToJsonElement(jsonStr).jsonArray + val players = json[0].jsonArray.map { it.jsonPrimitive.content } + for ((index, player) in players.withIndex()) { + val id = "0_$index" + playlists[id] = player + + val rawEpisodes = json[1].jsonArray[index].jsonArray + for (rawEpisode in rawEpisodes) { + val name = rawEpisode.jsonObject["name"]!!.jsonPrimitive.content + + val codePart = Ksoup.parse(rawEpisode.jsonObject["code"]!!.jsonPrimitive.content) + val url = codePart.attr("src") + + episodes.add(AnimeEpisode(name, url, id)) + } + } + } else { + val epResp = client.get("https://anitube.in.ua/engine/ajax/playlists.php?news_id=$newsId&xfield=playlist&user_hash=$userHash") { + expectSuccess = true + + header("User-Agent", userAgent) + } + + val epDocument = Ksoup.parse(Json.parseToJsonElement(epResp.bodyAsText()).jsonObject["response"]!!.jsonPrimitive.content) + + for (li in epDocument.getElementsByClass("playlists-lists")[0].getElementsByTag("li")) { + playlists[li.attr("data-id")] = li.text() + } + + for (li in epDocument.getElementsByClass("playlists-videos")[0].getElementsByTag("li")) { + episodes.add( + AnimeEpisode( + li.text(), + li.attr("data-file"), + li.attr("data-id") + ) + ) + } + } + + return AnimeInfo( + slug, name, imageUrl, description, rate, playlists, episodes.toList() + ) + } + + suspend fun getDirectUrlFromIFrame(url: String): String { + val resp = client.get(url) { + expectSuccess = true + + header("User-Agent", userAgent) + } + val body = resp.bodyAsText() + val fileUrl = body.split("\n").find { + it.contains("file:") || it.contains("src: ") + }!! + + return fileUrl.substringBeforeLast("\"").substringAfterLast("\"").substringAfterLast(",").substringAfterLast("]") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/utils/Character.kt b/app/src/main/java/com/crocoby/animeplayerua/utils/Character.kt new file mode 100644 index 0000000..3c4b603 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/utils/Character.kt @@ -0,0 +1,57 @@ +package com.crocoby.animeplayerua.utils + +import kotlin.Char.Companion.MIN_HIGH_SURROGATE +import kotlin.Char.Companion.MIN_LOW_SURROGATE + +/** + * Kotlin Multiplatform equivalent for `java.lang.Character` + * + * @author aSemy + */ + +internal object Character { + + /** + * See https://www.tutorialspoint.com/java/lang/character_issupplementarycodepoint.htm + * + * Determines whether the specified character (Unicode code point) is in the supplementary character range. + * The supplementary character range in the Unicode system falls in `U+10000` to `U+10FFFF`. + * + * The Unicode code points are divided into two categories: + * Basic Multilingual Plane (BMP) code points and Supplementary code points. + * BMP code points are present in the range U+0000 to U+FFFF. + * + * Whereas, supplementary characters are rare characters that are not represented using the original 16-bit Unicode. + * For example, these type of characters are used in Chinese or Japanese scripts and hence, are required by the + * applications used in these countries. + * + * @returns `true` if the specified code point falls in the range of supplementary code points + * ([MIN_SUPPLEMENTARY_CODE_POINT] to [MAX_CODE_POINT], inclusive), `false` otherwise. + */ + internal fun isSupplementaryCodePoint(codePoint: Int): Boolean = + codePoint in MIN_SUPPLEMENTARY_CODE_POINT..MAX_CODE_POINT + + internal fun toCodePoint(highSurrogate: Char, lowSurrogate: Char): Int = + (highSurrogate.code shl 10) + lowSurrogate.code + SURROGATE_DECODE_OFFSET + + /** Basic Multilingual Plane (BMP) */ + internal fun isBmpCodePoint(codePoint: Int): Boolean = codePoint ushr 16 == 0 + + internal fun highSurrogateOf(codePoint: Int): Char = + ((codePoint ushr 10) + HIGH_SURROGATE_ENCODE_OFFSET.code).toChar() + + internal fun lowSurrogateOf(codePoint: Int): Char = + ((codePoint and 0x3FF) + MIN_LOW_SURROGATE.code).toChar() + + // private const val MIN_CODE_POINT: Int = 0x000000 + private const val MAX_CODE_POINT: Int = 0x10FFFF + + private const val MIN_SUPPLEMENTARY_CODE_POINT: Int = 0x10000 + + private const val SURROGATE_DECODE_OFFSET: Int = + MIN_SUPPLEMENTARY_CODE_POINT - + (MIN_HIGH_SURROGATE.code shl 10) - + MIN_LOW_SURROGATE.code + + private const val HIGH_SURROGATE_ENCODE_OFFSET: Char = MIN_HIGH_SURROGATE - (MIN_SUPPLEMENTARY_CODE_POINT ushr 10) +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/utils/FocusBorderModifier.kt b/app/src/main/java/com/crocoby/animeplayerua/utils/FocusBorderModifier.kt new file mode 100644 index 0000000..507765d --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/utils/FocusBorderModifier.kt @@ -0,0 +1,33 @@ +package com.crocoby.animeplayerua.utils + +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +@Composable +fun Modifier.focusBorder( + enabled: Boolean = true, + shape: Shape = RectangleShape +): Modifier { + if (!enabled) { + return this + } + + var focusState = remember { MutableInteractionSource() } + val focused = focusState.collectIsFocusedAsState().value + + var modifier = this then Modifier.focusable(true, focusState) + if (focused) { + modifier = modifier.border(1.dp, Color.White, shape) + } + + return modifier +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/utils/TimeFormat.kt b/app/src/main/java/com/crocoby/animeplayerua/utils/TimeFormat.kt new file mode 100644 index 0000000..26c4fd2 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/utils/TimeFormat.kt @@ -0,0 +1,20 @@ +package com.crocoby.animeplayerua.utils + +fun msToTimeString(ms: Int): String { + val seconds = ms / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + + val dSeconds = seconds % 60 + val dMinutes = minutes % 60 + + val sSeconds = dSeconds.toString() + val sMinutes = dMinutes.toString() + val sHours = hours.toString() + + return if (hours > 0) { + "$sHours:${"0".repeat(2-sMinutes.length)}${sMinutes}:${"0".repeat(2-sSeconds.length)}${sSeconds}" + } else { + "${"0".repeat(2-sMinutes.length)}${sMinutes}:${"0".repeat(2-sSeconds.length)}${sSeconds}" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/utils/UrlEncoderUtil.kt b/app/src/main/java/com/crocoby/animeplayerua/utils/UrlEncoderUtil.kt new file mode 100644 index 0000000..e55c52f --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/utils/UrlEncoderUtil.kt @@ -0,0 +1,228 @@ +package com.crocoby.animeplayerua.utils + +/** + * Most defensive approach to URL encoding and decoding. + * + * - Rules determined by combining the unreserved character set from + * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#page-13) with the percent-encode set from + * [application/x-www-form-urlencoded](https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set). + * + * - Both specs above support percent decoding of two hexadecimal digits to a binary octet, however their unreserved + * set of characters differs and `application/x-www-form-urlencoded` adds conversion of space to `+`, which has the + * potential to be misunderstood. + * + * - This library encodes with rules that will be decoded correctly in either case. + * + * @author Geert Bevin (gbevin(remove) at uwyn dot com) + * @author Erik C. Thauvin (erik@thauvin.net) + **/ +object UrlEncoderUtil { + private val hexDigits = "0123456789ABCDEF".toCharArray() + + /** + * A [BooleanArray] with entries for the [character codes][Char.code] of + * + * * `0-9`, + * * `A-Z`, + * * `a-z` + * + * set to `true`. + */ + private val unreservedChars = BooleanArray('z'.code + 1).apply { + set('-'.code, true) + set('.'.code, true) + set('_'.code, true) + for (c in '0'..'9') { + set(c.code, true) + } + for (c in 'A'..'Z') { + set(c.code, true) + } + for (c in 'a'..'z') { + set(c.code, true) + } + } + + // see https://www.rfc-editor.org/rfc/rfc3986#page-13 + // and https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set + private fun Char.isUnreserved(): Boolean { + return this <= 'z' && unreservedChars[code] + } + + private fun StringBuilder.appendEncodedDigit(digit: Int) { + this.append(hexDigits[digit and 0x0F]) + } + + private fun StringBuilder.appendEncodedByte(ch: Int) { + this.append("%") + this.appendEncodedDigit(ch shr 4) + this.appendEncodedDigit(ch) + } + + /** + * Transforms a provided [String] into a new string, containing decoded URL characters in the UTF-8 + * encoding. + */ + @JvmStatic + @JvmOverloads + fun decode(source: String, plusToSpace: Boolean = false): String { + if (source.isEmpty()) { + return source + } + + val length = source.length + val out = StringBuilder(length) + var bytesBuffer: ByteArray? = null + var bytesPos = 0 + var i = 0 + var started = false + while (i < length) { + val ch = source[i] + if (ch == '%') { + if (!started) { + out.append(source, 0, i) + started = true + } + if (bytesBuffer == null) { + // the remaining characters divided by the length of the encoding format %xx, is the maximum number + // of bytes that can be extracted + bytesBuffer = ByteArray((length - i) / 3) + } + i++ + require(length >= i + 2) { "Incomplete trailing escape ($ch) pattern" } + try { + val v = source.substring(i, i + 2).toInt(16) + require(v in 0..0xFF) { "Illegal escape value" } + bytesBuffer[bytesPos++] = v.toByte() + i += 2 + } catch (e: NumberFormatException) { + throw IllegalArgumentException("Illegal characters in escape sequence: $e.message", e) + } + } else { + if (bytesBuffer != null) { + out.append(bytesBuffer.decodeToString(0, bytesPos)) + started = true + bytesBuffer = null + bytesPos = 0 + } + if (plusToSpace && ch == '+') { + if (!started) { + out.append(source, 0, i) + started = true + } + out.append(" ") + } else if (started) { + out.append(ch) + } + i++ + } + } + + if (bytesBuffer != null) { + out.append(bytesBuffer.decodeToString(0, bytesPos)) + } + + return if (!started) source else out.toString() + } + + /** + * Transforms a provided [String] object into a new string, containing only valid URL + * characters in the UTF-8 encoding. + * + * - Letters, numbers, unreserved (`_-!.'()*`) and allowed characters are left intact. + */ + @JvmStatic + @JvmOverloads + fun encode(source: String, allow: String = "", spaceToPlus: Boolean = false): String { + if (source.isEmpty()) { + return source + } + var out: StringBuilder? = null + var i = 0 + while (i < source.length) { + val ch = source[i] + if (ch.isUnreserved() || ch in allow) { + out?.append(ch) + i++ + } else { + if (out == null) { + out = StringBuilder(source.length) + out.append(source, 0, i) + } + val cp = source.codePointAt(i) + when { + cp < 0x80 -> { + if (spaceToPlus && ch == ' ') { + out.append('+') + } else { + out.appendEncodedByte(cp) + } + i++ + } + + Character.isBmpCodePoint(cp) -> { + for (b in ch.toString().encodeToByteArray()) { + out.appendEncodedByte(b.toInt()) + } + i++ + } + + Character.isSupplementaryCodePoint(cp) -> { + val high = Character.highSurrogateOf(cp) + val low = Character.lowSurrogateOf(cp) + for (b in charArrayOf(high, low).concatToString().encodeToByteArray()) { + out.appendEncodedByte(b.toInt()) + } + i += 2 + } + } + } + } + + return out?.toString() ?: source + } + + /** + * Returns the Unicode code point at the specified index. + * + * The `index` parameter is the regular `CharSequence` index, i.e. the number of `Char`s from the start of the character + * sequence. + * + * If the code point at the specified index is part of the Basic Multilingual Plane (BMP), its value can be represented + * using a single `Char` and this method will behave exactly like [CharSequence.get]. + * Code points outside the BMP are encoded using a surrogate pair – a `Char` containing a value in the high surrogate + * range followed by a `Char` containing a value in the low surrogate range. Together these two `Char`s encode a single + * code point in one of the supplementary planes. This method will do the necessary decoding and return the value of + * that single code point. + * + * In situations where surrogate characters are encountered that don't form a valid surrogate pair starting at `index`, + * this method will return the surrogate code point itself, behaving like [CharSequence.get]. + * + * If the `index` is out of bounds of this character sequence, this method throws an [IndexOutOfBoundsException]. + * + * ```kotlin + * // Text containing code points outside the BMP (encoded as a surrogate pairs) + * val text = "\uD83E\uDD95\uD83E\uDD96" + * + * var index = 0 + * while (index < text.length) { + * val codePoint = text.codePointAt(index) + * // (Do something with codePoint...) + * index += CodePoints.charCount(codePoint) + * } + * ``` + */ + private fun CharSequence.codePointAt(index: Int): Int { + if (index !in indices) throw IndexOutOfBoundsException("index $index was not in range $indices") + + val firstChar = this[index] + if (firstChar.isHighSurrogate()) { + val nextChar = getOrNull(index + 1) + if (nextChar?.isLowSurrogate() == true) { + return Character.toCodePoint(firstChar, nextChar) + } + } + + return firstChar.code + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimeCard.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimeCard.kt new file mode 100644 index 0000000..1d615ee --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimeCard.kt @@ -0,0 +1,111 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.crocoby.animeplayerua.AnimeItem +import com.crocoby.animeplayerua.UiColors +import com.crocoby.animeplayerua.utils.focusBorder +import com.crocoby.animeplayerua.zeroCardElevation +import io.kamel.image.KamelImage +import io.kamel.image.asyncPainterResource + +@Composable +fun AnimeCard( + animeItem: AnimeItem, + onClick: () -> Unit +) { + val painterResource = asyncPainterResource(data = animeItem.imageUrl) + val shape = RoundedCornerShape(24.dp) + + Card( + modifier = Modifier + .width(160.dp) + .height(250.dp) + .focusBorder(shape = shape) + .clip(shape) + .clickable { + onClick() + }, + shape = shape, + elevation = CardDefaults.zeroCardElevation(), + colors = CardDefaults.cardColors( + containerColor = UiColors.greyBar + ) + ) { + Column(Modifier.padding(8.dp)) { + KamelImage( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .weight(1f), + resource = { + painterResource + }, + contentDescription = "animePicture", + contentScale = ContentScale.Crop, + ) + Spacer(Modifier.height(8.dp)) + Text( + animeItem.name, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + ) + } + } +} + +@Composable +fun AnimeCardLoading() { + Card( + modifier = Modifier + .width(160.dp) + .height(250.dp), + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.zeroCardElevation(), + colors = CardDefaults.cardColors( + containerColor = UiColors.greyBar + ) + ) { + Column(Modifier.padding(8.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .weight(1f) + .background(UiColors.background), + ) + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .clip(RoundedCornerShape(16.dp)) + .background(UiColors.background), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimeCategory.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimeCategory.kt new file mode 100644 index 0000000..fba3594 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimeCategory.kt @@ -0,0 +1,105 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.crocoby.animeplayerua.AnimeItem +import com.crocoby.animeplayerua.UiColors +import com.crocoby.animeplayerua.UiConstants +import kotlinx.coroutines.launch + +@Composable +fun AnimeCategory( + title: String, + animeList: List, + onClick: (AnimeItem) -> Unit +) { + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + Column { + HorizontalPadding { + Text( + title, + style = TextStyle( + fontSize = 28.sp, + fontWeight = FontWeight.Medium + ) + ) + } + Spacer(Modifier.height(20.dp)) + LazyRow( + modifier = Modifier + .draggable( + orientation = Orientation.Horizontal, + state = DraggableState { delta -> + coroutineScope.launch { + lazyListState.scrollBy(-delta) + } + } + ), + state = lazyListState, + horizontalArrangement = Arrangement.spacedBy(15.dp), + contentPadding = PaddingValues(horizontal = UiConstants.horizontalScreenPadding) + ) { + items(animeList) { item -> AnimeCard(item) { onClick(item) } } + } + } +} + +@Composable +fun AnimeCategoryLoading( + count: Int +) { + Column { + HorizontalPadding { + Box( + modifier = Modifier + .width(240.dp) + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(UiColors.greyBar), + ) + } + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier + .padding(start = UiConstants.horizontalScreenPadding) + .wrapContentWidth( + unbounded = true, + align = Alignment.Start + ), + horizontalArrangement = Arrangement.spacedBy(15.dp), + ) { + for (i in 1..count) { + AnimeCardLoading() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimePlaylistRow.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimePlaylistRow.kt new file mode 100644 index 0000000..7efb471 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/AnimePlaylistRow.kt @@ -0,0 +1,82 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.crocoby.animeplayerua.UiColors +import com.crocoby.animeplayerua.UiConstants +import com.crocoby.animeplayerua.utils.focusBorder +import kotlinx.coroutines.launch + +@Composable +fun AnimePlaylistRow( + playlistItems: List, + selected: Int = 0, + onToggle: (Int) -> Unit +) { + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState(0) + val buttonShape = RoundedCornerShape(24.dp) + + Box( + modifier = Modifier + .draggable( + orientation = Orientation.Horizontal, + state = DraggableState { delta -> + coroutineScope.launch { + scrollState.scrollBy(-delta) + } + } + ) + .horizontalScroll(scrollState) + .fillMaxWidth(), + ) { + Row( + Modifier.padding(horizontal = UiConstants.horizontalScreenPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for ((index, item) in playlistItems.withIndex()) { + Button( + modifier = Modifier.focusBorder(shape = buttonShape), + onClick = { + if (selected != index) { + onToggle(index) + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (selected == index) UiColors.buttons else UiColors.greyBar + ), + shape = buttonShape, + contentPadding = PaddingValues(8.dp) + ) { + Text( + text = item, + maxLines = 1, + style = TextStyle( + fontSize = 14.sp + ) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/ApplicationScaffold.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/ApplicationScaffold.kt new file mode 100644 index 0000000..576dd57 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/ApplicationScaffold.kt @@ -0,0 +1,103 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.compose.currentBackStackEntryAsState +import com.crocoby.animeplayerua.MenuItem +import com.crocoby.animeplayerua.Routes +import com.crocoby.animeplayerua.navController + +@Composable +fun ApplicationScaffold(content: @Composable () -> Unit) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + Scaffold( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures(onTap = { + keyboardController?.hide() + focusManager.clearFocus(true) + }) + }, + bottomBar = { + BottomAppBar(tonalElevation = 0.dp) { + for (item in listOf( + MenuItem( + Icons.Filled.Home, + Icons.Outlined.Home, + "Популярне", + listOf(Routes.HOME, Routes.SEARCH) + ), + MenuItem( + Icons.Filled.PlayArrow, + Icons.Outlined.PlayArrow, + "Списки", + listOf(Routes.PLAYLISTS) + ) + )) { + val currentRoute = Routes.clearParams(navController!!.currentBackStackEntryAsState().value?.destination?.route?:"") + val selected = item.routes.contains(currentRoute) + NavigationBarItem( + selected = selected, + label = { + Text( + item.title, + style = TextStyle( + fontSize = 14.sp, + ) + ) + }, + icon = { + Icon( + imageVector = if (selected) item.selectedIcon else item.unselectedIcon, + contentDescription = item.routes[0] + "Icon" + ) + }, + onClick = { + if (item.routes[0] != currentRoute) { + if (currentRoute == Routes.SEARCH && item.routes[0] == Routes.HOME) { + navController!!.navigateUp() + } else { + navController!!.navigate(item.routes[0]) { + launchSingleTop = true + restoreState = true + + popUpTo(0) { + saveState = true + } + } + } + } + } + ) + } + } + } + ) { innerPadding -> + Box( + modifier = Modifier.padding(innerPadding) + ) { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/EpisodeButton.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/EpisodeButton.kt new file mode 100644 index 0000000..0bc1480 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/EpisodeButton.kt @@ -0,0 +1,93 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.crocoby.animeplayerua.UiColors +import com.crocoby.animeplayerua.utils.focusBorder +import com.crocoby.animeplayerua.zeroCardElevation + +@Composable +fun EpisodeButton( + name: String, + continueWatching: Boolean = false, + onWatch: () -> Unit +) { + val cardShape = RoundedCornerShape(24.dp) + + Card( + modifier = Modifier + .fillMaxWidth() + .focusBorder(shape = cardShape) + .clip(cardShape) + .clickable { onWatch() }, + colors = CardDefaults.cardColors( + containerColor = if (continueWatching) UiColors.buttons else UiColors.greyBar + ), + shape = cardShape, + elevation = CardDefaults.zeroCardElevation(), + ) { + Row( + modifier = Modifier.padding(8.dp).height(IntrinsicSize.Max), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .fillMaxHeight() + .wrapContentHeight(align = Alignment.CenterVertically), + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ), + ) + Spacer(Modifier.width(8.dp).weight(1f)) + if (continueWatching) { + Text( + text = "Продовжити", + style = TextStyle( + fontSize = 12.sp + ) + ) + } else { + IconButton( + onClick = { + onWatch() + } + ) { + Icon( + modifier = Modifier.height(24.dp), + imageVector = Icons.Filled.PlayArrow, + contentDescription = "playIcon" + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/ScreenPaddings.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/ScreenPaddings.kt new file mode 100644 index 0000000..92d9dcd --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/ScreenPaddings.kt @@ -0,0 +1,34 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.crocoby.animeplayerua.UiConstants + +@Composable +fun HorizontalPadding( + content: @Composable () -> Unit +) { + Box(Modifier.padding(horizontal = UiConstants.horizontalScreenPadding)) { + content() + } +} + +@Composable +fun VerticalPadding( + content: @Composable () -> Unit +) { + Box(Modifier.padding(vertical = UiConstants.verticalScreenPadding),) { + content() + } +} + +@Composable +fun TopPadding( + content: @Composable () -> Unit +) { + Box(Modifier.padding(top = UiConstants.verticalScreenPadding)) { + content() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/SearchField.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/SearchField.kt new file mode 100644 index 0000000..88a9e65 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/SearchField.kt @@ -0,0 +1,110 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.crocoby.animeplayerua.UiColors +import kotlin.math.min + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchField( + query: String = "", + onSubmition: (String) -> Unit +) { + var searchQuery by remember { mutableStateOf(query) } + + BasicTextField( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + value = TextFieldValue( + searchQuery, + selection = TextRange(searchQuery.length) + ), + onValueChange = { + val text = it.text + searchQuery = text.substring(0, min(text.length, 32)) + }, + singleLine = true, + textStyle = TextStyle( + color = UiColors.text, + fontSize = 14.sp, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSubmition(searchQuery) + } + ), + cursorBrush = SolidColor(Color.White) + ) { + TextFieldDefaults.DecorationBox( + value = searchQuery, + leadingIcon = { + Icon( + modifier = Modifier.height(16.dp), + imageVector = Icons.Filled.Search, + contentDescription = "searchIcon", + ) + }, + placeholder = { + Text( + text = "Пошук аніме по назві", + style = TextStyle( + color = Color(0xFFBEBEBE), + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ), + ) + }, + innerTextField = it, + singleLine = true, + enabled = true, + interactionSource = remember { MutableInteractionSource() }, + visualTransformation = VisualTransformation.None, + shape = RoundedCornerShape(24.dp), + contentPadding = PaddingValues( + horizontal = 16.dp + ), + colors = TextFieldDefaults.colors( + focusedContainerColor = UiColors.greyBar, + unfocusedContainerColor = UiColors.greyBar, + disabledContainerColor = UiColors.greyBar, + errorContainerColor = UiColors.greyBar, + + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/TextBanner.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/TextBanner.kt new file mode 100644 index 0000000..61e9267 --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/TextBanner.kt @@ -0,0 +1,32 @@ +package com.crocoby.animeplayerua.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun TextBanner(text: String) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.padding(8.dp), + text = text, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crocoby/animeplayerua/widgets/VideoPlayer.kt b/app/src/main/java/com/crocoby/animeplayerua/widgets/VideoPlayer.kt new file mode 100644 index 0000000..75b0a3e --- /dev/null +++ b/app/src/main/java/com/crocoby/animeplayerua/widgets/VideoPlayer.kt @@ -0,0 +1,155 @@ +package com.crocoby.animeplayerua.widgets + +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import com.crocoby.animeplayerua.navController +import com.google.accompanist.systemuicontroller.SystemUiController +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +fun hideBars(systemUiController: SystemUiController) { + systemUiController.isSystemBarsVisible = false + systemUiController.isStatusBarVisible = false + systemUiController.isNavigationBarVisible = false +} + +fun showBars(systemUiController: SystemUiController) { + systemUiController.isSystemBarsVisible = true + systemUiController.isStatusBarVisible = true + systemUiController.isNavigationBarVisible = true +} + +@OptIn(UnstableApi::class) +@Composable +fun VideoPlayer(modifier: Modifier, url: String) { + val context = LocalContext.current.applicationContext + val systemUiController = rememberSystemUiController() + val focusRequester = remember { FocusRequester() } + val lifecycleOwner = LocalLifecycleOwner.current + + val exoPlayer: ExoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + repeatMode = ExoPlayer.REPEAT_MODE_OFF + playWhenReady = true + prepare() + + hideBars(systemUiController) + + play() + } + } + val playerView = remember { + val t = PlayerView(context).apply { + player = exoPlayer + useController = true + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + t.setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { + when (it) { + PlayerView.VISIBLE -> showBars(systemUiController) + PlayerView.INVISIBLE, PlayerView.GONE -> hideBars(systemUiController) + } + }) + + t + } + + LaunchedEffect(true) { + launch { + while (true) { + if (systemUiController.isSystemBarsVisible || systemUiController.isStatusBarVisible || systemUiController.isNavigationBarVisible) { + playerView.showController() + } + delay(500) + } + } + } + + AndroidView( + modifier = modifier + .safeDrawingPadding() + .focusable() + .focusRequester(focusRequester) + .onKeyEvent { + if (it.type != KeyEventType.KeyUp) { + return@onKeyEvent false + } + when (it.key) { + Key.DirectionCenter -> { + if (playerView.isControllerFullyVisible) { + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + exoPlayer.play() + } + } else { + playerView.showController() + } + } + Key.DirectionLeft -> { + exoPlayer.seekBack() + } + Key.DirectionRight -> { + exoPlayer.seekForward() + } + Key.Back -> { + if (playerView.isControllerFullyVisible) { + playerView.hideController() + } else { + navController!!.navigateUp() + } + } + } + true + }, + factory = { + playerView + }, + onRelease = {}, + ) + + DisposableEffect(Unit) { + focusRequester.requestFocus() + val observer = LifecycleEventObserver { source, event -> + if (event == Lifecycle.Event.ON_PAUSE) { + exoPlayer.pause() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + exoPlayer.release() + lifecycleOwner.lifecycle.removeObserver(observer) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_done_all.xml b/app/src/main/res/drawable/baseline_done_all.xml new file mode 100644 index 0000000..bbcd89e --- /dev/null +++ b/app/src/main/res/drawable/baseline_done_all.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_pause.xml b/app/src/main/res/drawable/baseline_pause.xml new file mode 100644 index 0000000..f962aed --- /dev/null +++ b/app/src/main/res/drawable/baseline_pause.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..b5cd46e --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..e9bbdf6 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ecbf1fa --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + AnimePlayer UA + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..53b79c5 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +