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