diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java index 137320896d..4bb93ac42e 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java @@ -1,13 +1,10 @@ package net.kdt.pojavlaunch.fragments; -import static net.kdt.pojavlaunch.Tools.dialogOnUiThread; import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog; import static net.kdt.pojavlaunch.Tools.hasOnlineProfile; import static net.kdt.pojavlaunch.Tools.openPath; -import static net.kdt.pojavlaunch.Tools.runOnUiThread; import static net.kdt.pojavlaunch.Tools.shareLog; -import android.app.Dialog; import android.content.Intent; import android.os.Bundle; import android.view.View; @@ -17,7 +14,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import com.kdt.mcgui.mcVersionSpinner; @@ -39,63 +35,93 @@ public class MainMenuFragment extends Fragment { private mcVersionSpinner mVersionSpinner; - public MainMenuFragment(){ + public MainMenuFragment() { super(R.layout.fragment_launcher); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - Button mNewsButton = view.findViewById(R.id.news_button); - Button mDiscordButton = view.findViewById(R.id.discord_button); - Button mCustomControlButton = view.findViewById(R.id.custom_control_button); - Button mInstallJarButton = view.findViewById(R.id.install_jar_button); - Button mShareLogsButton = view.findViewById(R.id.share_logs_button); - Button mOpenDirectoryButton = view.findViewById(R.id.open_files_button); + Button mNewsButton = view.findViewById(R.id.news_button); + Button mDiscordButton = view.findViewById(R.id.discord_button); + Button mCustomControlButton = view.findViewById(R.id.custom_control_button); + Button mInstallJarButton = view.findViewById(R.id.install_jar_button); + Button mShareLogsButton = view.findViewById(R.id.share_logs_button); + Button mManageModsButton = view.findViewById(R.id.open_files_button); + Button mOpenDirectoryButton = view.findViewById(R.id.open_directory_button); + Button mModStoreButton = view.findViewById(R.id.mod_store_button); ImageButton mEditProfileButton = view.findViewById(R.id.edit_profile_button); Button mPlayButton = view.findViewById(R.id.play_button); mVersionSpinner = view.findViewById(R.id.mc_version_spinner); + // Wiki mNewsButton.setOnClickListener(v -> Tools.openURL(requireActivity(), Tools.URL_HOME)); + + // Discord mDiscordButton.setOnClickListener(v -> Tools.openURL(requireActivity(), getString(R.string.discord_invite))); - mCustomControlButton.setOnClickListener(v -> startActivity(new Intent(requireContext(), CustomControlsActivity.class))); + + // Custom controls + mCustomControlButton.setOnClickListener(v -> + startActivity(new Intent(requireContext(), CustomControlsActivity.class))); + + // Mod Store → individual mod search + if (mModStoreButton != null) + mModStoreButton.setOnClickListener(v -> + Tools.swapFragment(requireActivity(), ModsSearchFragment.class, ModsSearchFragment.TAG, null)); + + // Execute .jar if (hasOnlineProfile()) { mInstallJarButton.setOnClickListener(v -> runInstallerWithConfirmation(false)); mInstallJarButton.setOnLongClickListener(v -> { runInstallerWithConfirmation(true); return true; }); - } else mInstallJarButton.setOnClickListener(v -> hasNoOnlineProfileDialog(requireActivity())); - mEditProfileButton.setOnClickListener(v -> mVersionSpinner.openProfileEditor(requireActivity())); - - mPlayButton.setOnClickListener(v -> { - ExtraCore.setValue(ExtraConstants.LAUNCH_GAME, true); - }); - - mShareLogsButton.setOnClickListener((v) -> shareLog(requireContext())); - - mOpenDirectoryButton.setOnClickListener((v)-> { - if (Tools.isDemoProfile(v.getContext())){ // Say a different message when on demo profile since they might see the hidden demo folder - hasNoOnlineProfileDialog(getActivity(), getString(R.string.demo_unsupported), getString(R.string.change_account)); - } else if (!hasOnlineProfile()) { // Otherwise display the generic pop-up to log in - hasNoOnlineProfileDialog(requireActivity()); - } else openPath(v.getContext(), getCurrentProfileDirectory(), false); + } else { + mInstallJarButton.setOnClickListener(v -> hasNoOnlineProfileDialog(requireActivity())); + } + + // Share logs + if (mShareLogsButton != null) + mShareLogsButton.setOnClickListener(v -> shareLog(requireContext())); + + // Manage Mods & Tools → new UI + mManageModsButton.setOnClickListener(v -> + Tools.swapFragment(requireActivity(), ManageModsFragment.class, ManageModsFragment.TAG, null)); + + // Open game directory (original behaviour) + if (mOpenDirectoryButton != null) { + mOpenDirectoryButton.setOnClickListener(v -> { + if (Tools.isDemoProfile(v.getContext())) { + hasNoOnlineProfileDialog(getActivity(), + getString(R.string.demo_unsupported), getString(R.string.change_account)); + } else if (!hasOnlineProfile()) { + hasNoOnlineProfileDialog(requireActivity()); + } else { + openPath(v.getContext(), getCurrentProfileDirectory(), false); + } + }); + } - }); + // Edit profile + mEditProfileButton.setOnClickListener(v -> mVersionSpinner.openProfileEditor(requireActivity())); + // Play + mPlayButton.setOnClickListener(v -> ExtraCore.setValue(ExtraConstants.LAUNCH_GAME, true)); - mNewsButton.setOnLongClickListener((v)->{ + // Long-press wiki → gamepad mapper (hidden) + mNewsButton.setOnLongClickListener(v -> { Tools.swapFragment(requireActivity(), GamepadMapperFragment.class, GamepadMapperFragment.TAG, null); return true; }); } private File getCurrentProfileDirectory() { - String currentProfile = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); - if(!Tools.isValidString(currentProfile)) return new File(Tools.DIR_GAME_NEW); + String currentProfile = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (!Tools.isValidString(currentProfile)) return new File(Tools.DIR_GAME_NEW); LauncherProfiles.load(); MinecraftProfile profileObject = LauncherProfiles.mainProfileJson.profiles.get(currentProfile); - if(profileObject == null) return new File(Tools.DIR_GAME_NEW); + if (profileObject == null) return new File(Tools.DIR_GAME_NEW); return Tools.getGameDirPath(profileObject); } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java new file mode 100644 index 0000000000..57b620947f --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java @@ -0,0 +1,95 @@ +package net.kdt.pojavlaunch.fragments; + +import android.os.Bundle; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.fragments.ModsSearchFragment; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.modloaders.InstalledModAdapter; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; +import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; + +import java.io.File; + +public class ManageModsFragment extends Fragment { + + public static final String TAG = "ManageModsFragment"; + + public ManageModsFragment() { + super(R.layout.fragment_manage_mods); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + ImageButton backButton = view.findViewById(R.id.manage_mods_back); + ImageButton addButton = view.findViewById(R.id.manage_mods_add); + TextView title = view.findViewById(R.id.manage_mods_title); + RecyclerView recycler = view.findViewById(R.id.manage_mods_recycler); + View emptyState = view.findViewById(R.id.manage_mods_empty); + + // Back + backButton.setOnClickListener(v -> Tools.removeCurrentFragment(requireActivity())); + + // Add → open mod store + addButton.setOnClickListener(v -> + Tools.swapFragment(requireActivity(), ModsSearchFragment.class, ModsSearchFragment.TAG, null)); + + // Title: "ProfileName - Mods" + String profileName = getCurrentProfileName(); + title.setText(profileName.isEmpty() + ? getString(R.string.mcl_button_manage_mods) + : profileName + " - Mods"); + + // Build mod list + File modsDir = getModsDir(); + InstalledModAdapter adapter = new InstalledModAdapter(modsDir, isEmpty -> { + recycler.setVisibility(isEmpty ? View.GONE : View.VISIBLE); + emptyState.setVisibility(isEmpty ? View.VISIBLE : View.GONE); + }); + + recycler.setLayoutManager(new LinearLayoutManager(requireContext())); + recycler.setAdapter(adapter); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private String getCurrentProfileName() { + try { + String key = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (key == null || key.isEmpty()) return ""; + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); + if (profile == null) return ""; + return profile.name != null ? profile.name : key; + } catch (Exception e) { + return ""; + } + } + + private File getModsDir() { + try { + String key = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (key != null && !key.isEmpty()) { + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); + if (profile != null) { + File gameDir = Tools.getGameDirPath(profile); + return new File(gameDir, "mods"); + } + } + } catch (Exception ignored) {} + return new File(Tools.DIR_GAME_NEW, "mods"); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java new file mode 100644 index 0000000000..701bc13199 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java @@ -0,0 +1,298 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.math.MathUtils; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.modloaders.modpacks.ModItemAdapter; +import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi; +import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail; +import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; +import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants; +import net.kdt.pojavlaunch.prefs.LauncherPreferences; +import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper; +import net.kdt.pojavlaunch.profiles.VersionSelectorDialog; +import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; +import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; +import net.kdt.pojavlaunch.utils.DownloadUtils; + +import java.io.File; +import java.io.IOException; + +/** + * Searches and installs individual mods (not modpacks) into the current instance's mods folder. + * Auto-detects MC version from the selected profile's lastVersionId. + */ +public class ModsSearchFragment extends Fragment implements ModItemAdapter.SearchResultCallback { + + public static final String TAG = "ModsSearchFragment"; + + private View mOverlay; + private float mOverlayTopCache; + + private final RecyclerView.OnScrollListener mOverlayPositionListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + mOverlay.setY(MathUtils.clamp(mOverlay.getY() - dy, -mOverlay.getHeight(), mOverlayTopCache)); + } + }; + + private EditText mSearchEditText; + private ImageButton mFilterButton; + private RecyclerView mRecyclerview; + private ModItemAdapter mModItemAdapter; + private ProgressBar mSearchProgressBar; + private TextView mStatusTextView; + private ColorStateList mDefaultTextColor; + + private ModpackApi mModpackApi; + private final SearchFilters mSearchFilters; + + public ModsSearchFragment() { + super(R.layout.fragment_mod_search); + mSearchFilters = new SearchFilters(); + mSearchFilters.isModpack = false; // individual mods, not modpacks + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + // Wrap CommonApi so installation goes to mods folder instead of creating a new instance + mModpackApi = new ModsInstallApi(context.getString(R.string.curseforge_api_key)); + // Auto-detect MC version from current profile + mSearchFilters.mcVersion = detectMcVersion(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mModItemAdapter = new ModItemAdapter(getResources(), mModpackApi, this); + ProgressKeeper.addTaskCountListener(mModItemAdapter); + mOverlayTopCache = getResources().getDimension(R.dimen.fragment_padding_medium); + + mOverlay = view.findViewById(R.id.search_mod_overlay); + mSearchEditText = view.findViewById(R.id.search_mod_edittext); + mSearchProgressBar = view.findViewById(R.id.search_mod_progressbar); + mRecyclerview = view.findViewById(R.id.search_mod_list); + mStatusTextView = view.findViewById(R.id.search_mod_status_text); + mFilterButton = view.findViewById(R.id.search_mod_filter); + + mDefaultTextColor = mStatusTextView.getTextColors(); + + mRecyclerview.setLayoutManager(new LinearLayoutManager(getContext())); + mRecyclerview.setAdapter(mModItemAdapter); + mRecyclerview.addOnScrollListener(mOverlayPositionListener); + + mSearchEditText.setOnEditorActionListener((v, actionId, event) -> { + searchMods(mSearchEditText.getText().toString()); + mSearchEditText.clearFocus(); + return false; + }); + + mOverlay.post(() -> { + int overlayHeight = mOverlay.getHeight(); + mRecyclerview.setPadding( + mRecyclerview.getPaddingLeft(), + mRecyclerview.getPaddingTop() + overlayHeight, + mRecyclerview.getPaddingRight(), + mRecyclerview.getPaddingBottom()); + }); + + mFilterButton.setOnClickListener(v -> displayFilterDialog()); + + // Override hint — the layout uses fragment_mod_search which says "Search for modpacks" + mSearchEditText.setHint(R.string.hint_search_mod); + + searchMods(null); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + ProgressKeeper.removeTaskCountListener(mModItemAdapter); + mRecyclerview.removeOnScrollListener(mOverlayPositionListener); + } + + @Override + public void onSearchFinished() { + mSearchProgressBar.setVisibility(View.GONE); + mStatusTextView.setVisibility(View.GONE); + } + + @Override + public void onSearchError(int error) { + mSearchProgressBar.setVisibility(View.GONE); + mStatusTextView.setVisibility(View.VISIBLE); + switch (error) { + case ERROR_INTERNAL: + mStatusTextView.setTextColor(Color.RED); + mStatusTextView.setText(R.string.search_modpack_error); + break; + case ERROR_NO_RESULTS: + mStatusTextView.setTextColor(mDefaultTextColor); + mStatusTextView.setText(R.string.search_modpack_no_result); + break; + } + } + + private void searchMods(String name) { + mSearchProgressBar.setVisibility(View.VISIBLE); + mSearchFilters.name = name == null ? "" : name; + mModItemAdapter.performSearchQuery(mSearchFilters); + } + + private void displayFilterDialog() { + AlertDialog dialog = new AlertDialog.Builder(requireContext()) + .setView(R.layout.dialog_mod_filters) + .create(); + + dialog.setOnShowListener(dialogInterface -> { + TextView mSelectedVersion = dialog.findViewById(R.id.search_mod_selected_mc_version_textview); + Button mSelectVersionButton = dialog.findViewById(R.id.search_mod_mc_version_button); + Button mApplyButton = dialog.findViewById(R.id.search_mod_apply_filters); + + assert mSelectedVersion != null; + assert mSelectVersionButton != null; + assert mApplyButton != null; + + mSelectVersionButton.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> mSelectedVersion.setText(id))); + + mSelectedVersion.setText(mSearchFilters.mcVersion); + + mApplyButton.setOnClickListener(v -> { + mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); + searchMods(mSearchEditText.getText().toString()); + dialogInterface.dismiss(); + }); + }); + + dialog.show(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** Parse MC version from lastVersionId like "fabric-loader-0.16.14-1.21.4" → "1.21.4" or "1.21.4" → "1.21.4" */ + private String detectMcVersion() { + try { + String key = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (key == null || key.isEmpty()) return null; + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); + if (profile == null || profile.lastVersionId == null) return null; + + String vid = profile.lastVersionId; + // fabric-loader-X.Y.Z-MC → last segment after last "-MC" block + // forge-MC-loaderVer → second segment + // 1.21.4 → as-is + if (vid.startsWith("fabric-loader-") || vid.startsWith("quilt-loader-")) { + // fabric-loader-0.16.14-1.21.4 — MC is everything after the loader version + String[] parts = vid.split("-"); + if (parts.length >= 4) return parts[parts.length - 1]; + } + if (vid.contains("-forge-") || vid.contains("-neoforge-")) { + // 1.21.4-forge-xxx — MC is first part + return vid.split("-")[0]; + } + // Plain version id + return vid; + } catch (Exception e) { + return null; + } + } + + // ── ModsInstallApi ─────────────────────────────────────────────────────── + + /** + * Wraps CommonApi and overrides handleInstallation to download the mod JAR + * directly into the current instance's mods/ folder instead of installing a modpack. + */ + private static class ModsInstallApi extends CommonApi { + + ModsInstallApi(String curseforgeApiKey) { + super(curseforgeApiKey); + } + + @Override + public void handleInstallation(Context context, ModDetail modDetail, int selectedVersion) { + if (modDetail.isModpack) { + // Fall back to normal modpack install + super.handleInstallation(context, modDetail, selectedVersion); + return; + } + + String url = modDetail.versionUrls[selectedVersion]; + if (url == null || url.isEmpty()) { + Tools.showErrorRemote(context, R.string.modpack_install_download_failed, + new IOException("No download URL available for this mod")); + return; + } + + // Determine file name from URL + String fileName = url.substring(url.lastIndexOf('/') + 1); + if (!fileName.endsWith(".jar")) fileName += ".jar"; + // Strip query params if present + if (fileName.contains("?")) fileName = fileName.substring(0, fileName.indexOf('?')); + + File modsDir = getModsDir(); + if (!modsDir.exists()) modsDir.mkdirs(); + File destFile = new File(modsDir, fileName); + + String finalFileName = fileName; + ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.global_waiting); + PojavApplication.sExecutorService.execute(() -> { + try { + DownloadUtils.downloadFile(url, destFile); + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + Tools.runOnUiThread(() -> + Toast.makeText(context, + context.getString(R.string.mod_install_success, finalFileName), + Toast.LENGTH_LONG).show() + ); + } catch (Exception e) { + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + Tools.showErrorRemote(context, R.string.modpack_install_download_failed, e); + } + }); + } + + private static File getModsDir() { + try { + String key = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (key != null && !key.isEmpty()) { + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); + if (profile != null) { + return new File(Tools.getGameDirPath(profile), "mods"); + } + } + } catch (Exception ignored) {} + return new File(Tools.DIR_GAME_NEW, "mods"); + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java new file mode 100644 index 0000000000..d330f9f717 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java @@ -0,0 +1,187 @@ +package net.kdt.pojavlaunch.modloaders; + +import android.app.AlertDialog; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.SwitchCompat; +import androidx.recyclerview.widget.RecyclerView; + +import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.R; + +import java.io.File; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Adapter for the installed mods list (fragment_manage_mods). + * Enable/disable: rename .jar <-> .jar.disabled + * Icons: extracted from pack.png inside the JAR + * Delete: removes file with confirmation + */ +public class InstalledModAdapter extends RecyclerView.Adapter { + + public interface EmptyStateListener { + void onEmptyStateChanged(boolean isEmpty); + } + + private final List mMods = new ArrayList<>(); + private final EmptyStateListener mEmptyListener; + private final Handler mMainHandler = new Handler(Looper.getMainLooper()); + + public InstalledModAdapter(File modsDir, EmptyStateListener listener) { + mEmptyListener = listener; + if (modsDir != null && modsDir.isDirectory()) { + File[] files = modsDir.listFiles(f -> f.isFile() && + (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + if (files != null) { + Arrays.sort(files, (a, b) -> a.getName().compareToIgnoreCase(b.getName())); + for (File f : files) mMods.add(new ModEntry(f)); + } + } + notifyEmptyState(); + } + + @NonNull + @Override + public ModViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_installed_mod, parent, false); + return new ModViewHolder(v); + } + + @Override + public void onBindViewHolder(@NonNull ModViewHolder holder, int position) { + holder.bind(mMods.get(position), position); + } + + @Override + public int getItemCount() { + return mMods.size(); + } + + private void notifyEmptyState() { + if (mEmptyListener != null) mEmptyListener.onEmptyStateChanged(mMods.isEmpty()); + } + + // ── ViewHolder ──────────────────────────────────────────────────────── + + class ModViewHolder extends RecyclerView.ViewHolder { + final ImageView icon; + final TextView name, version; + final SwitchCompat toggle; + final ImageButton delete; + + ModViewHolder(@NonNull View itemView) { + super(itemView); + icon = itemView.findViewById(R.id.installed_mod_icon); + name = itemView.findViewById(R.id.installed_mod_name); + version = itemView.findViewById(R.id.installed_mod_version); + toggle = itemView.findViewById(R.id.installed_mod_toggle); + delete = itemView.findViewById(R.id.installed_mod_delete); + } + + void bind(ModEntry entry, int adapterPos) { + name.setText(entry.displayName()); + version.setText(entry.file.getName()); + icon.setImageResource(R.drawable.ic_add_modded); // default placeholder + + // Async icon extraction from pack.png inside the JAR + PojavApplication.sExecutorService.execute(() -> { + Bitmap bmp = extractPackPng(entry.file); + if (bmp != null) { + mMainHandler.post(() -> { + if (getBindingAdapterPosition() == adapterPos) { + icon.setImageBitmap(bmp); + } + }); + } + }); + + toggle.setOnCheckedChangeListener(null); + toggle.setChecked(entry.enabled); + toggle.setOnCheckedChangeListener((btn, isChecked) -> entry.setEnabled(isChecked)); + + delete.setOnClickListener(v -> { + Context ctx = v.getContext(); + new AlertDialog.Builder(ctx) + .setTitle(ctx.getString(R.string.manage_mods_delete_confirm, entry.displayName())) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (d, i) -> { + entry.file.delete(); + int pos = getBindingAdapterPosition(); + if (pos != RecyclerView.NO_POSITION) { + mMods.remove(pos); + notifyItemRemoved(pos); + notifyEmptyState(); + } + }) + .show(); + }); + } + } + + // ── ModEntry ────────────────────────────────────────────────────────── + + static class ModEntry { + File file; + boolean enabled; + + ModEntry(File f) { + this.file = f; + this.enabled = !f.getName().endsWith(".disabled"); + } + + String displayName() { + String n = file.getName(); + if (n.endsWith(".jar.disabled")) n = n.substring(0, n.length() - 13); + else if (n.endsWith(".jar")) n = n.substring(0, n.length() - 4); + return n; + } + + void setEnabled(boolean enable) { + if (enable == this.enabled) return; + File target = enable + ? new File(file.getParent(), file.getName().replace(".jar.disabled", ".jar")) + : new File(file.getParent(), file.getName() + ".disabled"); + if (file.renameTo(target)) { + file = target; + this.enabled = enable; + } + } + } + + // ── Icon extraction ─────────────────────────────────────────────────── + + /** Try to extract pack.png from inside the mod JAR. Returns null on failure. */ + private static Bitmap extractPackPng(File jarFile) { + try (ZipFile zip = new ZipFile(jarFile)) { + // Common icon filenames used by Fabric/Forge mods + String[] candidates = {"pack.png", "icon.png", "logo.png"}; + for (String candidate : candidates) { + ZipEntry entry = zip.getEntry(candidate); + if (entry == null) continue; + try (InputStream is = zip.getInputStream(entry)) { + Bitmap bmp = BitmapFactory.decodeStream(is); + if (bmp != null) return bmp; + } + } + } catch (Exception ignored) {} + return null; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java index cb4c17ab18..f263763974 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java @@ -117,7 +117,15 @@ public ModDetail getModDetails(ModItem item) { versionNames[i] = modDetail.get("displayName").getAsString(); JsonElement downloadUrl = modDetail.get("downloadUrl"); - versionUrls[i] = downloadUrl.getAsString(); + if (downloadUrl == null || downloadUrl.isJsonNull()) { + // CF restricts direct download for some mods — build edge CDN URL instead + int fileId = modDetail.get("id").getAsInt(); + String fileName = modDetail.get("fileName").getAsString(); + versionUrls[i] = String.format("https://edge.forgecdn.net/files/%s/%s/%s", + fileId / 1000, fileId % 1000, fileName); + } else { + versionUrls[i] = downloadUrl.getAsString(); + } JsonArray gameVersions = modDetail.getAsJsonArray("gameVersions"); for(JsonElement jsonElement : gameVersions) { diff --git a/app_pojavlauncher/src/main/res/drawable/ic_folder_managed.xml b/app_pojavlauncher/src/main/res/drawable/ic_folder_managed.xml new file mode 100644 index 0000000000..fe658c7b08 --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/ic_folder_managed.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app_pojavlauncher/src/main/res/layout-land/fragment_launcher.xml b/app_pojavlauncher/src/main/res/layout-land/fragment_launcher.xml index 3cf2f0b6a3..ded29b0e21 100644 --- a/app_pojavlauncher/src/main/res/layout-land/fragment_launcher.xml +++ b/app_pojavlauncher/src/main/res/layout-land/fragment_launcher.xml @@ -2,12 +2,9 @@ + + + + + + + app:layout_constraintTop_toBottomOf="@id/mod_store_button"/> + + + + + app:layout_constraintTop_toBottomOf="@id/open_directory_button"/> + app:layout_constraintTop_toBottomOf="@id/install_jar_button"/> - app:layout_constraintTop_toBottomOf="@id/share_logs_button" /> - - - - - @@ -140,8 +156,6 @@ android:id="@+id/play_button" android:layout_width="0dp" android:layout_height="wrap_content" - - android:layout_marginEnd="@dimen/_8sdp" android:text="@string/main_play" android:textAllCaps="true" @@ -150,4 +164,3 @@ app:layout_constraintTop_toTopOf="@+id/mc_version_spinner" /> - diff --git a/app_pojavlauncher/src/main/res/layout/activity_pojav_launcher.xml b/app_pojavlauncher/src/main/res/layout/activity_pojav_launcher.xml index b4d20e0378..cd011b9934 100644 --- a/app_pojavlauncher/src/main/res/layout/activity_pojav_launcher.xml +++ b/app_pojavlauncher/src/main/res/layout/activity_pojav_launcher.xml @@ -10,10 +10,8 @@ android:id="@+id/account_spinner" android:layout_width="match_parent" android:layout_height="@dimen/_52sdp" - android:dropDownWidth="@dimen/_250sdp" android:dropDownVerticalOffset="@dimen/_52sdp" - app:layout_constraintTop_toTopOf="parent" /> - - - - - \ No newline at end of file + diff --git a/app_pojavlauncher/src/main/res/layout/fragment_launcher.xml b/app_pojavlauncher/src/main/res/layout/fragment_launcher.xml index ad6f7229d1..3cf9d3a9e7 100644 --- a/app_pojavlauncher/src/main/res/layout/fragment_launcher.xml +++ b/app_pojavlauncher/src/main/res/layout/fragment_launcher.xml @@ -1,142 +1,200 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/fragment_menu_main" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/background_app"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/app_pojavlauncher/src/main/res/layout/fragment_manage_mods.xml b/app_pojavlauncher/src/main/res/layout/fragment_manage_mods.xml new file mode 100644 index 0000000000..0b3ded9e97 --- /dev/null +++ b/app_pojavlauncher/src/main/res/layout/fragment_manage_mods.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app_pojavlauncher/src/main/res/layout/item_installed_mod.xml b/app_pojavlauncher/src/main/res/layout/item_installed_mod.xml new file mode 100644 index 0000000000..85ec97f470 --- /dev/null +++ b/app_pojavlauncher/src/main/res/layout/item_installed_mod.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app_pojavlauncher/src/main/res/layout/view_mod.xml b/app_pojavlauncher/src/main/res/layout/view_mod.xml index cfca0a84a2..fb01be89a6 100644 --- a/app_pojavlauncher/src/main/res/layout/view_mod.xml +++ b/app_pojavlauncher/src/main/res/layout/view_mod.xml @@ -5,35 +5,34 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/background_line" - android:paddingHorizontal="@dimen/_2sdp" - android:paddingVertical="@dimen/_2sdp" + android:background="@drawable/background_card" android:layout_marginVertical="@dimen/padding_medium" - > + android:foreground="?attr/selectableItemBackground" + android:clickable="true" + android:focusable="true"> + - tools:src="@mipmap/ic_launcher_foreground" - /> - + + tools:src="@drawable/ic_modrinth" /> + + tools:text="Sodium" /> + + tools:text="The fastest and most compatible rendering optimization mod for Minecraft." /> - - - - + app:layout_constraintTop_toBottomOf="@id/mod_thumbnail_imageview" /> - \ No newline at end of file + diff --git a/app_pojavlauncher/src/main/res/values/strings.xml b/app_pojavlauncher/src/main/res/values/strings.xml index 29abdf299d..e224268e21 100644 --- a/app_pojavlauncher/src/main/res/values/strings.xml +++ b/app_pojavlauncher/src/main/res/values/strings.xml @@ -92,6 +92,12 @@ Wait Select Retry + Back + Add + No mods installed for this profile. + Delete %s? + %s installed to mods folder. + Search for mods Default Off @@ -426,6 +432,13 @@ Change controller key bindings Allows you to modify the keyboard keys bound to each controller button Discord + Quick Actions + Instance Tools + Mod Store + Settings + Manage Mods & Tools + Install Modpack (.mrpack/zip) + Delete Your GPU is not capable of rendering above 7 render distance without Sodium or other similar mods. The render distance will be automatically reduced when you click "OK". Open game directory https://discord.gg/8WzF2RVAq4