package ani.dantotsu.download.anime import android.animation.ObjectAnimator import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment import android.text.Editable import android.text.TextWatcher import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation import android.view.animation.LayoutAnimationController import android.view.animation.OvershootInterpolator import android.widget.AbsListView import android.widget.AutoCompleteTextView import android.widget.GridView import android.widget.ImageView import android.widget.TextView import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView import androidx.core.view.marginBottom import androidx.fragment.app.Fragment import androidx.media3.common.util.UnstableApi import ani.dantotsu.R import ani.dantotsu.bottomBar import ani.dantotsu.currActivity import ani.dantotsu.currContext import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.initActivity import ani.dantotsu.loadData import ani.dantotsu.logger import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsActivity import ani.dantotsu.navBarHeight import ani.dantotsu.setSafeOnClickListener import ani.dantotsu.settings.SettingsDialogFragment import ani.dantotsu.settings.UserInterfaceSettings import ani.dantotsu.snackString import ani.dantotsu.statusBarHeight import com.google.android.material.card.MaterialCardView import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.textfield.TextInputLayout import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnimeImpl import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisodeImpl import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File import kotlin.math.max import kotlin.math.min class OfflineAnimeFragment : Fragment(), OfflineAnimeSearchListener { private val downloadManager = Injekt.get() private var downloads: List = listOf() private lateinit var gridView: GridView private lateinit var adapter: OfflineAnimeAdapter private var uiSettings: UserInterfaceSettings = loadData("ui_settings") ?: UserInterfaceSettings() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val view = inflater.inflate(R.layout.fragment_offline_page, container, false) val textInputLayout = view.findViewById(R.id.offlineMangaSearchBar) textInputLayout.hint = "Anime" val currentColor = textInputLayout.boxBackgroundColor val semiTransparentColor = (currentColor and 0x00FFFFFF) or 0xA8000000.toInt() textInputLayout.boxBackgroundColor = semiTransparentColor val materialCardView = view.findViewById(R.id.offlineMangaAvatarContainer) materialCardView.setCardBackgroundColor(semiTransparentColor) val typedValue = TypedValue() requireContext().theme?.resolveAttribute(android.R.attr.windowBackground, typedValue, true) val color = typedValue.data val animeUserAvatar = view.findViewById(R.id.offlineMangaUserAvatar) animeUserAvatar.setSafeOnClickListener { val dialogFragment = SettingsDialogFragment.newInstance(SettingsDialogFragment.Companion.PageType.OfflineANIME) dialogFragment.show((it.context as AppCompatActivity).supportFragmentManager, "dialog") } if (!uiSettings.immersiveMode) { view.rootView.fitsSystemWindows = true } val colorOverflow = currContext()?.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE) ?.getBoolean("colorOverflow", false) ?: false if (!colorOverflow) { textInputLayout.boxBackgroundColor = (color and 0x00FFFFFF) or 0x28000000.toInt() materialCardView.setCardBackgroundColor((color and 0x00FFFFFF) or 0x28000000.toInt()) } val searchView = view.findViewById(R.id.animeSearchBarText) searchView.addTextChangedListener(object : TextWatcher { override fun afterTextChanged(s: Editable?) { } override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { } override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { onSearchQuery(s.toString()) } }) var style = context?.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE) ?.getInt("offline_view", 0) val layoutList = view.findViewById(R.id.downloadedList) val layoutcompact = view.findViewById(R.id.downloadedGrid) var selected = when (style) { 0 -> layoutList 1 -> layoutcompact else -> layoutList } selected.alpha = 1f fun selected(it: ImageView) { selected.alpha = 0.33f selected = it selected.alpha = 1f } layoutList.setOnClickListener { selected(it as ImageView) style = 0 context?.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.edit() ?.putInt("offline_view", style!!)?.apply() gridView.visibility = View.GONE gridView = view.findViewById(R.id.gridView) gridView.adapter = adapter gridView.scheduleLayoutAnimation() gridView.visibility = View.VISIBLE adapter.notifyNewGrid() grid() } layoutcompact.setOnClickListener { selected(it as ImageView) style = 1 context?.getSharedPreferences("Dantotsu", Context.MODE_PRIVATE)?.edit() ?.putInt("offline_view", style!!)?.apply() gridView.visibility = View.GONE gridView = view.findViewById(R.id.gridView1) gridView.adapter = adapter gridView.scheduleLayoutAnimation() gridView.visibility = View.VISIBLE adapter.notifyNewGrid() grid() } gridView = if (style == 0) view.findViewById(R.id.gridView) else view.findViewById(R.id.gridView1) gridView.visibility = View.VISIBLE getDownloads() val fadeIn = AlphaAnimation(0f, 1f) fadeIn.duration = 200 // animations pog val animation = LayoutAnimationController(fadeIn) gridView.layoutAnimation = animation adapter = OfflineAnimeAdapter(requireContext(), downloads, this) gridView.adapter = adapter gridView.scheduleLayoutAnimation() grid() val total = view.findViewById(R.id.total) total.text = if (gridView.count > 0) "Anime (${gridView.count})" else "Empty List" return view } @OptIn(UnstableApi::class) private fun grid(){ gridView.setOnItemClickListener { parent, view, position, id -> // Get the OfflineAnimeModel that was clicked val item = adapter.getItem(position) as OfflineAnimeModel val media = downloadManager.animeDownloadedTypes.firstOrNull { it.title == item.title } media?.let { MediaDetailsActivity.mediaSingleton = getMedia(it) startActivity( Intent(requireContext(), MediaDetailsActivity::class.java) .putExtra("download", true) ) } ?: run { snackString("no media found") } } gridView.setOnItemLongClickListener { parent, view, position, id -> // Get the OfflineAnimeModel that was clicked val item = adapter.getItem(position) as OfflineAnimeModel val type: DownloadedType.Type = DownloadedType.Type.ANIME // Alert dialog to confirm deletion val builder = androidx.appcompat.app.AlertDialog.Builder(requireContext(), R.style.MyPopup) builder.setTitle("Delete ${item.title}?") builder.setMessage("Are you sure you want to delete ${item.title}?") builder.setPositiveButton("Yes") { _, _ -> downloadManager.removeMedia(item.title, type) val mediaIds = requireContext().getSharedPreferences(getString(R.string.anime_downloads), Context.MODE_PRIVATE) ?.all?.filter { it.key.contains(item.title) }?.values ?: emptySet() if (mediaIds.isEmpty()) { snackString("No media found") // if this happens, terrible things have happened } for (mediaId in mediaIds) { ani.dantotsu.download.video.Helper.downloadManager(requireContext()) .removeDownload(mediaId.toString()) } getDownloads() adapter.setItems(downloads) } builder.setNegativeButton("No") { _, _ -> // Do nothing } val dialog = builder.show() dialog.window?.setDimAmount(0.8f) true } } override fun onSearchQuery(query: String) { adapter.onSearchQuery(query) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) var height = statusBarHeight if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val displayCutout = activity?.window?.decorView?.rootWindowInsets?.displayCutout if (displayCutout != null) { if (displayCutout.boundingRects.size > 0) { height = max( statusBarHeight, min( displayCutout.boundingRects[0].width(), displayCutout.boundingRects[0].height() ) ) } } } val scrollTop = view.findViewById(R.id.mangaPageScrollTop) scrollTop.translationY = -(navBarHeight + bottomBar.height + bottomBar.marginBottom).toFloat() val visible = false fun animate() { val start = if (visible) 0f else 1f val end = if (!visible) 0f else 1f ObjectAnimator.ofFloat(scrollTop, "scaleX", start, end).apply { duration = 300 interpolator = OvershootInterpolator(2f) start() } ObjectAnimator.ofFloat(scrollTop, "scaleY", start, end).apply { duration = 300 interpolator = OvershootInterpolator(2f) start() } } scrollTop.setOnClickListener { gridView.smoothScrollToPosition(0) } // Assuming 'scrollTop' is a view that you want to hide/show scrollTop.visibility = View.GONE gridView.setOnScrollListener(object : AbsListView.OnScrollListener { override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { // Implement behavior for different scroll states if needed } override fun onScroll( view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int ) { val first = view.getChildAt(0) val visibility = first != null && first.top < -height scrollTop.visibility = if (visibility) View.VISIBLE else View.GONE } }) initActivity(requireActivity()) } override fun onResume() { super.onResume() getDownloads() adapter.notifyDataSetChanged() } override fun onPause() { super.onPause() downloads = listOf() } override fun onDestroy() { super.onDestroy() downloads = listOf() } override fun onStop() { super.onStop() downloads = listOf() } private fun getDownloads() { downloads = listOf() val animeTitles = downloadManager.animeDownloadedTypes.map { it.title }.distinct() val newAnimeDownloads = mutableListOf() for (title in animeTitles) { val _downloads = downloadManager.animeDownloadedTypes.filter { it.title == title } val download = _downloads.first() val offlineAnimeModel = loadOfflineAnimeModel(download) newAnimeDownloads += offlineAnimeModel } downloads = newAnimeDownloads } private fun getMedia(downloadedType: DownloadedType): Media? { val type = if (downloadedType.type == DownloadedType.Type.ANIME) { "Anime" } else if (downloadedType.type == DownloadedType.Type.MANGA) { "Manga" } else { "Novel" } val directory = File( currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/$type/${downloadedType.title}" ) //load media.json and convert to media class with gson return try { val gson = GsonBuilder() .registerTypeAdapter(SChapter::class.java, InstanceCreator { SChapterImpl() // Provide an instance of SChapterImpl }) .registerTypeAdapter(SAnime::class.java, InstanceCreator { SAnimeImpl() // Provide an instance of SAnimeImpl }) .registerTypeAdapter(SEpisode::class.java, InstanceCreator { SEpisodeImpl() // Provide an instance of SEpisodeImpl }) .create() val media = File(directory, "media.json") val mediaJson = media.readText() gson.fromJson(mediaJson, Media::class.java) } catch (e: Exception) { logger("Error loading media.json: ${e.message}") logger(e.printStackTrace()) FirebaseCrashlytics.getInstance().recordException(e) null } } private fun loadOfflineAnimeModel(downloadedType: DownloadedType): OfflineAnimeModel { val type = if (downloadedType.type == DownloadedType.Type.MANGA) { "Manga" } else if (downloadedType.type == DownloadedType.Type.ANIME) { "Anime" } else { "Novel" } val directory = File( currContext()?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "Dantotsu/$type/${downloadedType.title}" ) //load media.json and convert to media class with gson try { val media = File(directory, "media.json") val mediaJson = media.readText() val mediaModel = getMedia(downloadedType)!! val cover = File(directory, "cover.jpg") val coverUri: Uri? = if (cover.exists()) { Uri.fromFile(cover) } else null val banner = File(directory, "banner.jpg") val bannerUri: Uri? = if (banner.exists()) { Uri.fromFile(banner) } else null val title = mediaModel.nameMAL ?: mediaModel.nameRomaji val score = ((if (mediaModel.userScore == 0) (mediaModel.meanScore ?: 0) else mediaModel.userScore) / 10.0).toString() val isOngoing = mediaModel.status == currActivity()!!.getString(R.string.status_releasing) val isUserScored = mediaModel.userScore != 0 val watchedEpisodes = (mediaModel.userProgress ?: "~").toString() val totalEpisode = if (mediaModel.anime?.nextAiringEpisode != null) (mediaModel.anime.nextAiringEpisode.toString() + " | " + (mediaModel.anime.totalEpisodes ?: "~").toString()) else (mediaModel.anime?.totalEpisodes ?: "~").toString() val chapters = " Chapters" val totalEpisodesList = if (mediaModel.anime?.nextAiringEpisode != null) (mediaModel.anime.nextAiringEpisode.toString()) else (mediaModel.anime?.totalEpisodes ?: "~").toString() return OfflineAnimeModel( title, score, totalEpisode, totalEpisodesList, watchedEpisodes, type, chapters, isOngoing, isUserScored, coverUri, bannerUri ) } catch (e: Exception) { logger("Error loading media.json: ${e.message}") logger(e.printStackTrace()) FirebaseCrashlytics.getInstance().recordException(e) return OfflineAnimeModel( "unknown", "0", "??", "??", "??", "movie", "hmm", false, false, null, null ) } } } interface OfflineAnimeSearchListener { fun onSearchQuery(query: String) }