commit ca09dcbd69c4fda215c08a8a5b1ddc269bcc5f08 Author: Wickedness Date: Mon Jun 8 23:50:28 2026 +0900 Initial Android app implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c080cd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +.idea/ +.kotlin/ +build/ +app/build/ +local.properties +captures/ +*.iml +*.apk +*.aab +*.ap_ diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..d32097d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,21 @@ +plugins { + id "com.android.application" +} + +android { + namespace "com.example.youtubefavoritesearch" + compileSdk 36 + + defaultConfig { + applicationId "com.example.youtubefavoritesearch" + minSdk 23 + targetSdk 36 + versionCode 1 + versionName "1.0" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..91adc35 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/youtubefavoritesearch/MainActivity.java b/app/src/main/java/com/example/youtubefavoritesearch/MainActivity.java new file mode 100644 index 0000000..205d52f --- /dev/null +++ b/app/src/main/java/com/example/youtubefavoritesearch/MainActivity.java @@ -0,0 +1,2720 @@ +package com.example.youtubefavoritesearch; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.Html; +import android.text.InputType; +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.view.Gravity; +import android.view.DragEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.HorizontalScrollView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MainActivity extends Activity { + private static final String PREFS_NAME = "youtube_favorite_search"; + private static final String VIDEOS_KEY = "videos"; + private static final String CATEGORIES_KEY = "categories"; + private static final String THEME_MODE_KEY = "theme_mode"; + private static final String ACCENT_INDEX_KEY = "accent_index"; + private static final String SORT_MODE_KEY = "sort_mode"; + private static final String UNCATEGORIZED_ONLY_KEY = "uncategorized_only"; + private static final String SEARCH_HISTORY_KEY = "search_history"; + + private static final String ALL_CATEGORY_ID = "all"; + private static final int MAX_SEARCH_HISTORY = 10; + private static final int REQUEST_EXPORT_BACKUP = 2001; + private static final int REQUEST_IMPORT_BACKUP = 2002; + + private static final int SORT_LATEST = 0; + private static final int SORT_OLDEST = 1; + private static final int SORT_TITLE = 2; + private static final int SORT_RECENT = 3; + + private static final int THEME_SYSTEM = 1001; + private static final int THEME_LIGHT = 1002; + private static final int THEME_DARK = 1003; + + private static final String LOADING_TITLE = "제목 불러오는 중"; + + private static final int[] ACCENT_COLORS = { + Color.rgb(47, 111, 237), + Color.rgb(226, 57, 53), + Color.rgb(11, 143, 103), + Color.rgb(124, 58, 237), + Color.rgb(221, 111, 0) + }; + private static final String[] ACCENT_NAMES = {"블루", "레드", "그린", "퍼플", "오렌지"}; + + private final ArrayList videos = new ArrayList<>(); + private final ArrayList categories = new ArrayList<>(); + private final ArrayList searchHistory = new ArrayList<>(); + private final ArrayList selectedVideoIds = new ArrayList<>(); + private final Map thumbnailCache = new ConcurrentHashMap<>(); + + private ExecutorService executor; + private Handler mainHandler; + private LinearLayout listContainer; + private EditText urlInput; + private EditText searchInput; + private TextView emptyView; + private ThemePalette palette; + private int themeMode = THEME_SYSTEM; + private int accentIndex = 0; + private int sortMode = SORT_LATEST; + private boolean uncategorizedOnly = false; + private boolean selectionMode = false; + private String selectedCategoryId = ALL_CATEGORY_ID; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + executor = Executors.newFixedThreadPool(4); + mainHandler = new Handler(Looper.getMainLooper()); + loadAppearance(); + loadSearchHistory(); + loadCategories(); + loadVideos(); + buildUi("", ""); + renderList(); + handleIncomingIntent(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIncomingIntent(intent); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode != RESULT_OK || data == null || data.getData() == null) { + return; + } + + if (requestCode == REQUEST_EXPORT_BACKUP) { + exportBackup(data.getData()); + } else if (requestCode == REQUEST_IMPORT_BACKUP) { + importBackup(data.getData()); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + executor.shutdownNow(); + } + + private void buildUi(String pendingUrl, String pendingSearch) { + palette = createPalette(); + applySystemBars(); + + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(palette.background); + root.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + LinearLayout header = new LinearLayout(this); + header.setOrientation(LinearLayout.VERTICAL); + header.setPadding(dp(18), dp(18), dp(18), dp(12)); + root.addView(header, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + LinearLayout titleRow = new LinearLayout(this); + titleRow.setOrientation(LinearLayout.HORIZONTAL); + titleRow.setGravity(Gravity.CENTER_VERTICAL); + header.addView(titleRow, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + LinearLayout titleColumn = new LinearLayout(this); + titleColumn.setOrientation(LinearLayout.VERTICAL); + titleRow.addView(titleColumn, new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + )); + + TextView title = new TextView(this); + title.setText("유튜브 즐겨찾기 검색"); + title.setTextColor(palette.textPrimary); + title.setTextSize(24); + title.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + titleColumn.addView(title); + + TextView guide = new TextView(this); + guide.setTextColor(palette.textSecondary); + guide.setTextSize(13); + guide.setText("탭으로 분류하고, 영상을 길게 눌러 여러 분류에 넣어두세요."); + LinearLayout.LayoutParams guideParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + guideParams.topMargin = dp(4); + titleColumn.addView(guide, guideParams); + + Button themeButton = new Button(this); + themeButton.setText("테마"); + themeButton.setTextSize(13); + themeButton.setTextColor(Color.WHITE); + themeButton.setAllCaps(false); + themeButton.setBackground(buttonBackground(palette.accent)); + themeButton.setOnClickListener(view -> showThemeDialog()); + LinearLayout.LayoutParams themeButtonParams = new LinearLayout.LayoutParams(dp(76), dp(44)); + themeButtonParams.leftMargin = dp(10); + titleRow.addView(themeButton, themeButtonParams); + + LinearLayout urlRow = new LinearLayout(this); + urlRow.setOrientation(LinearLayout.HORIZONTAL); + urlRow.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams urlRowParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + urlRowParams.topMargin = dp(14); + header.addView(urlRow, urlRowParams); + + urlInput = new EditText(this); + urlInput.setSingleLine(true); + urlInput.setHint("YouTube URL"); + urlInput.setInputType(InputType.TYPE_TEXT_VARIATION_URI); + urlInput.setImeOptions(EditorInfo.IME_ACTION_DONE); + urlInput.setTextSize(15); + urlInput.setTextColor(palette.textPrimary); + urlInput.setHintTextColor(palette.textSecondary); + urlInput.setBackground(inputBackground()); + urlInput.setPadding(dp(12), 0, dp(12), 0); + urlInput.setText(pendingUrl); + urlInput.setOnEditorActionListener((view, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + addVideoFromInput(); + return true; + } + return false; + }); + LinearLayout.LayoutParams urlInputParams = new LinearLayout.LayoutParams(0, dp(48), 1f); + urlRow.addView(urlInput, urlInputParams); + + Button pasteButton = new Button(this); + pasteButton.setText("붙여넣기"); + pasteButton.setTextSize(13); + pasteButton.setTextColor(palette.accent); + pasteButton.setAllCaps(false); + pasteButton.setBackground(inputBackground()); + pasteButton.setOnClickListener(view -> pasteFromClipboard()); + LinearLayout.LayoutParams pasteParams = new LinearLayout.LayoutParams(dp(92), dp(48)); + pasteParams.leftMargin = dp(8); + urlRow.addView(pasteButton, pasteParams); + + Button addButton = new Button(this); + addButton.setText("추가"); + addButton.setTextSize(13); + addButton.setTextColor(Color.WHITE); + addButton.setAllCaps(false); + addButton.setBackground(buttonBackground(palette.accent)); + addButton.setOnClickListener(view -> addVideoFromInput()); + LinearLayout.LayoutParams addParams = new LinearLayout.LayoutParams(dp(72), dp(48)); + addParams.leftMargin = dp(8); + urlRow.addView(addButton, addParams); + + searchInput = new EditText(this); + searchInput.setSingleLine(true); + searchInput.setHint("검색어"); + searchInput.setImeOptions(EditorInfo.IME_ACTION_SEARCH); + searchInput.setTextSize(16); + searchInput.setTextColor(palette.textPrimary); + searchInput.setHintTextColor(palette.textSecondary); + searchInput.setBackground(inputBackground()); + searchInput.setPadding(dp(12), 0, dp(12), 0); + searchInput.setText(pendingSearch); + searchInput.setOnEditorActionListener((view, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE) { + saveSearchQuery(searchInput.getText().toString()); + return false; + } + return false; + }); + searchInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + renderList(); + } + + @Override + public void afterTextChanged(Editable s) { + } + }); + LinearLayout.LayoutParams searchParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(48) + ); + searchParams.topMargin = dp(10); + header.addView(searchInput, searchParams); + + addSearchHistoryChips(header); + addControlBar(header); + addCategoryTabs(header); + + ScrollView scrollView = new ScrollView(this); + scrollView.setFillViewport(false); + root.addView(scrollView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1f + )); + + listContainer = new LinearLayout(this); + listContainer.setOrientation(LinearLayout.VERTICAL); + listContainer.setPadding(dp(14), dp(4), dp(14), dp(24)); + scrollView.addView(listContainer, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + emptyView = new TextView(this); + emptyView.setTextColor(palette.textSecondary); + emptyView.setTextSize(15); + emptyView.setGravity(Gravity.CENTER); + emptyView.setPadding(dp(18), dp(36), dp(18), dp(36)); + + setContentView(root); + } + + private void addSearchHistoryChips(LinearLayout header) { + if (searchHistory.isEmpty()) { + return; + } + + HorizontalScrollView scrollView = new HorizontalScrollView(this); + scrollView.setHorizontalScrollBarEnabled(false); + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + scrollParams.topMargin = dp(8); + header.addView(scrollView, scrollParams); + + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + scrollView.addView(row, new HorizontalScrollView.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(36) + )); + + for (String historyItem : searchHistory) { + Button chip = createSmallButton(historyItem); + chip.setTextColor(palette.textPrimary); + chip.setBackground(chipBackground()); + chip.setOnClickListener(view -> { + searchInput.setText(historyItem); + searchInput.setSelection(searchInput.getText().length()); + saveSearchQuery(historyItem); + }); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(34) + ); + params.rightMargin = dp(8); + row.addView(chip, params); + } + + Button clearButton = createSmallButton("지우기"); + clearButton.setTextColor(palette.textSecondary); + clearButton.setBackground(inputBackground()); + clearButton.setOnClickListener(view -> { + searchHistory.clear(); + saveSearchHistory(); + refreshUi(); + }); + row.addView(clearButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(34) + )); + } + + private void addControlBar(LinearLayout header) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + rowParams.topMargin = dp(10); + header.addView(row, rowParams); + + if (selectionMode) { + TextView countView = new TextView(this); + countView.setText(String.format(Locale.ROOT, "%d개 선택", selectedVideoIds.size())); + countView.setTextColor(palette.textPrimary); + countView.setTextSize(14); + countView.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + row.addView(countView, new LinearLayout.LayoutParams(0, dp(40), 1f)); + + Button applyButton = createToolbarButton("분류"); + applyButton.setTextColor(Color.WHITE); + applyButton.setBackground(buttonBackground(palette.accent)); + applyButton.setOnClickListener(view -> showBulkEditDialog()); + row.addView(applyButton, toolbarButtonParams(false)); + + Button deleteButton = createToolbarButton("삭제"); + deleteButton.setTextColor(Color.WHITE); + deleteButton.setBackground(buttonBackground(Color.rgb(214, 64, 64))); + deleteButton.setOnClickListener(view -> confirmDeleteSelectedVideos()); + row.addView(deleteButton, toolbarButtonParams(true)); + + Button cancelButton = createToolbarButton("취소"); + cancelButton.setOnClickListener(view -> { + selectionMode = false; + selectedVideoIds.clear(); + refreshUi(); + }); + row.addView(cancelButton, toolbarButtonParams(true)); + return; + } + + Button sortButton = createToolbarButton("정렬: " + sortModeName()); + sortButton.setOnClickListener(view -> showSortDialog()); + row.addView(sortButton, toolbarButtonParams(false)); + + Button filterButton = createToolbarButton("분류 없음"); + filterButton.setTextColor(uncategorizedOnly ? Color.WHITE : palette.textPrimary); + filterButton.setBackground(uncategorizedOnly ? buttonBackground(palette.accent) : inputBackground()); + filterButton.setOnClickListener(view -> { + uncategorizedOnly = !uncategorizedOnly; + if (uncategorizedOnly) { + selectedCategoryId = ALL_CATEGORY_ID; + } + saveAppearance(); + refreshUi(); + }); + row.addView(filterButton, toolbarButtonParams(true)); + + Button manageButton = createToolbarButton("관리"); + manageButton.setOnClickListener(view -> showManageDialog()); + row.addView(manageButton, toolbarButtonParams(true)); + + Button selectButton = createToolbarButton("선택"); + selectButton.setOnClickListener(view -> { + selectionMode = true; + selectedVideoIds.clear(); + refreshUi(); + }); + row.addView(selectButton, toolbarButtonParams(true)); + } + + private Button createToolbarButton(String text) { + Button button = createSmallButton(text); + button.setTextColor(palette.textPrimary); + button.setBackground(inputBackground()); + return button; + } + + private LinearLayout.LayoutParams toolbarButtonParams(boolean withLeftMargin) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(40) + ); + if (withLeftMargin) { + params.leftMargin = dp(8); + } + return params; + } + + private void addCategoryTabs(LinearLayout header) { + HorizontalScrollView scrollView = new HorizontalScrollView(this); + scrollView.setHorizontalScrollBarEnabled(false); + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + scrollParams.topMargin = dp(10); + header.addView(scrollView, scrollParams); + + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(0, 0, dp(4), 0); + scrollView.addView(row, new HorizontalScrollView.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + row.addView(createCategoryButton( + String.format(Locale.ROOT, "전체 %d", videos.size()), + ALL_CATEGORY_ID, + ALL_CATEGORY_ID.equals(selectedCategoryId), + null + )); + for (Category category : categories) { + row.addView(createCategoryButton( + String.format(Locale.ROOT, "%s %d", category.name, categoryVideoCount(category.id)), + category.id, + category.id.equals(selectedCategoryId), + category + )); + } + + Button addButton = createSmallButton("+"); + addButton.setTextColor(Color.WHITE); + addButton.setTextSize(18); + addButton.setContentDescription("분류 추가"); + addButton.setBackground(buttonBackground(palette.accent)); + addButton.setOnClickListener(view -> showCategoryDialog(null)); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(44), dp(40)); + params.leftMargin = dp(8); + row.addView(addButton, params); + } + + private Button createCategoryButton(String text, String categoryId, boolean selected, Category category) { + Button button = createSmallButton(text); + button.setTextColor(selected ? Color.WHITE : palette.textPrimary); + button.setBackground(categoryTabBackground(selected)); + button.setOnClickListener(view -> { + selectedCategoryId = categoryId; + if (!ALL_CATEGORY_ID.equals(categoryId) && uncategorizedOnly) { + uncategorizedOnly = false; + saveAppearance(); + } + refreshUi(); + }); + if (category != null) { + button.setOnLongClickListener(view -> { + showCategoryDialog(category); + return true; + }); + } + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(40) + ); + params.rightMargin = dp(8); + button.setLayoutParams(params); + return button; + } + + private Button createSmallButton(String text) { + Button button = new Button(this); + button.setText(text); + button.setTextSize(13); + button.setAllCaps(false); + button.setGravity(Gravity.CENTER); + button.setMinWidth(0); + button.setMinimumWidth(0); + button.setMinHeight(0); + button.setMinimumHeight(0); + button.setPadding(dp(14), 0, dp(14), 0); + return button; + } + + private void pasteFromClipboard() { + ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null || !clipboard.hasPrimaryClip()) { + Toast.makeText(this, "클립보드에 붙여넣을 링크가 없습니다.", Toast.LENGTH_SHORT).show(); + return; + } + ClipData clipData = clipboard.getPrimaryClip(); + if (clipData == null || clipData.getItemCount() == 0) { + Toast.makeText(this, "클립보드에 붙여넣을 링크가 없습니다.", Toast.LENGTH_SHORT).show(); + return; + } + CharSequence text = clipData.getItemAt(0).coerceToText(this); + if (text == null || text.toString().trim().isEmpty()) { + Toast.makeText(this, "클립보드에 붙여넣을 링크가 없습니다.", Toast.LENGTH_SHORT).show(); + return; + } + urlInput.setText(text.toString().trim()); + urlInput.setSelection(urlInput.getText().length()); + } + + private void addVideoFromInput() { + String rawUrl = urlInput.getText().toString().trim(); + boolean added = addVideoFromText(rawUrl, "영상이 추가되었습니다."); + if (added) { + urlInput.setText(""); + } + } + + private boolean addVideoFromText(String text, String successMessage) { + if (TextUtils.isEmpty(text) || text.trim().isEmpty()) { + Toast.makeText(this, "YouTube URL을 입력하세요.", Toast.LENGTH_SHORT).show(); + return false; + } + + String candidate = findYoutubeUrl(text); + if (candidate == null) { + candidate = text.trim(); + } + String normalizedUrl = normalizeUrl(candidate); + String videoId = extractVideoId(normalizedUrl); + if (videoId == null) { + Toast.makeText(this, "지원하는 YouTube 영상 URL이 아닙니다.", Toast.LENGTH_SHORT).show(); + return false; + } + + ArrayList defaultCategoryIds = defaultCategoryIdsForNewVideo(); + FavoriteVideo existingVideo = findVideoByVideoId(videoId); + if (existingVideo != null) { + showDuplicateVideoDialog(existingVideo, defaultCategoryIds); + return false; + } + + FavoriteVideo video = createVideo(videoId, defaultCategoryIds); + videos.add(0, video); + saveVideos(); + renderList(); + fetchMetadata(video); + Toast.makeText(this, successMessage, Toast.LENGTH_SHORT).show(); + return true; + } + + private FavoriteVideo createVideo(String videoId, ArrayList categoryIds) { + FavoriteVideo video = new FavoriteVideo(); + video.id = UUID.randomUUID().toString(); + video.videoId = videoId; + video.url = "https://www.youtube.com/watch?v=" + videoId; + video.thumbnailUrl = "https://img.youtube.com/vi/" + videoId + "/hqdefault.jpg"; + video.title = LOADING_TITLE; + video.description = ""; + video.author = ""; + video.alias = ""; + video.memo = ""; + video.categoryIds = new ArrayList<>(); + for (String categoryId : categoryIds) { + if (isKnownCategoryId(categoryId) && !video.categoryIds.contains(categoryId)) { + video.categoryIds.add(categoryId); + } + } + video.pinned = false; + video.addedAt = System.currentTimeMillis(); + video.lastOpenedAt = 0L; + return video; + } + + private ArrayList defaultCategoryIdsForNewVideo() { + ArrayList categoryIds = new ArrayList<>(); + if (!uncategorizedOnly && !ALL_CATEGORY_ID.equals(selectedCategoryId) && isKnownCategoryId(selectedCategoryId)) { + categoryIds.add(selectedCategoryId); + } + return categoryIds; + } + + private FavoriteVideo findVideoByVideoId(String videoId) { + for (FavoriteVideo video : videos) { + if (video.videoId.equals(videoId)) { + return video; + } + } + return null; + } + + private void showDuplicateVideoDialog(FavoriteVideo video, ArrayList categoryIdsToAdd) { + boolean hasCategoriesToAdd = false; + for (String categoryId : categoryIdsToAdd) { + if (!video.categoryIds.contains(categoryId)) { + hasCategoriesToAdd = true; + break; + } + } + + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle("이미 저장된 영상입니다") + .setMessage(emptyFallback(video.title, video.url)) + .setNegativeButton("취소", null) + .setNeutralButton("영상 편집", (dialogInterface, which) -> showVideoEditDialog(video)); + if (hasCategoriesToAdd) { + builder.setPositiveButton("현재 분류 추가", (dialogInterface, which) -> { + for (String categoryId : categoryIdsToAdd) { + if (!video.categoryIds.contains(categoryId)) { + video.categoryIds.add(categoryId); + } + } + saveVideos(); + renderList(); + Toast.makeText(this, "기존 영상에 분류를 추가했습니다.", Toast.LENGTH_SHORT).show(); + }); + } else { + builder.setPositiveButton("확인", null); + } + builder.show(); + } + + private void handleIncomingIntent(Intent intent) { + if (intent == null) { + return; + } + + String sharedText = null; + if (Intent.ACTION_SEND.equals(intent.getAction())) { + CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); + if (extraText != null) { + sharedText = extraText.toString(); + } + } else if (Intent.ACTION_VIEW.equals(intent.getAction()) && intent.getData() != null) { + sharedText = intent.getData().toString(); + } + + if (sharedText == null && intent.getClipData() != null && intent.getClipData().getItemCount() > 0) { + CharSequence clipText = intent.getClipData().getItemAt(0).coerceToText(this); + if (clipText != null) { + sharedText = clipText.toString(); + } + } + + if (sharedText == null) { + return; + } + + String youtubeUrl = findYoutubeUrl(sharedText); + if (youtubeUrl == null) { + return; + } + + showSharedAddDialog(youtubeUrl); + setIntent(new Intent()); + } + + private void showSharedAddDialog(String youtubeUrl) { + String normalizedUrl = normalizeUrl(youtubeUrl); + String videoId = extractVideoId(normalizedUrl); + if (videoId == null) { + Toast.makeText(this, "지원하는 YouTube 영상 URL이 아닙니다.", Toast.LENGTH_SHORT).show(); + return; + } + + ArrayList preselectedCategoryIds = defaultCategoryIdsForNewVideo(); + FavoriteVideo existingVideo = findVideoByVideoId(videoId); + if (existingVideo != null) { + showDuplicateVideoDialog(existingVideo, preselectedCategoryIds); + return; + } + + ScrollView scrollView = new ScrollView(this); + LinearLayout content = new LinearLayout(this); + content.setOrientation(LinearLayout.VERTICAL); + content.setPadding(dp(20), dp(8), dp(20), 0); + scrollView.addView(content, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + TextView label = dialogLabel("분류"); + content.addView(label); + + ArrayList checks = new ArrayList<>(); + if (categories.isEmpty()) { + TextView empty = new TextView(this); + empty.setText("등록된 분류가 없습니다."); + empty.setTextSize(14); + empty.setPadding(0, dp(8), 0, dp(8)); + content.addView(empty); + } else { + for (Category category : categories) { + CheckBox checkBox = new CheckBox(this); + checkBox.setText(category.name); + checkBox.setTextSize(15); + checkBox.setChecked(preselectedCategoryIds.contains(category.id)); + checkBox.setPadding(0, dp(4), 0, dp(4)); + checkBox.setTag(category.id); + checks.add(checkBox); + content.addView(checkBox); + } + } + + new AlertDialog.Builder(this) + .setTitle("공유 영상 추가") + .setView(scrollView) + .setPositiveButton("저장", (dialogInterface, which) -> { + ArrayList selectedCategories = new ArrayList<>(); + for (CheckBox checkBox : checks) { + if (checkBox.isChecked()) { + selectedCategories.add((String) checkBox.getTag()); + } + } + FavoriteVideo video = createVideo(videoId, selectedCategories); + videos.add(0, video); + saveVideos(); + renderList(); + fetchMetadata(video); + Toast.makeText(this, "공유된 영상이 추가되었습니다.", Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton("취소", null) + .show(); + } + + private void renderList() { + if (listContainer == null) { + return; + } + + listContainer.removeAllViews(); + String query = searchInput == null ? "" : searchInput.getText().toString().trim(); + ArrayList visibleVideos = filteredVideos(query); + + if (visibleVideos.isEmpty()) { + String message; + if (videos.isEmpty()) { + message = "저장된 영상이 없습니다."; + } else if (!ALL_CATEGORY_ID.equals(selectedCategoryId) && TextUtils.isEmpty(query)) { + message = "이 분류에 영상이 없습니다."; + } else { + message = "검색 결과가 없습니다."; + } + emptyView.setText(message); + listContainer.addView(emptyView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + return; + } + + for (FavoriteVideo video : visibleVideos) { + listContainer.addView(createVideoCard(video)); + } + } + + private ArrayList filteredVideos(String query) { + ArrayList result = new ArrayList<>(); + String normalizedQuery = normalizeForSearch(query); + for (FavoriteVideo video : videos) { + if (!matchesSelectedCategory(video)) { + continue; + } + if (uncategorizedOnly && !video.categoryIds.isEmpty()) { + continue; + } + if (normalizedQuery.isEmpty() || searchRank(video, normalizedQuery) < 3) { + result.add(video); + } + } + + Collections.sort(result, (left, right) -> { + int leftRank = normalizedQuery.isEmpty() ? 0 : searchRank(left, normalizedQuery); + int rightRank = normalizedQuery.isEmpty() ? 0 : searchRank(right, normalizedQuery); + if (leftRank != rightRank) { + return Integer.compare(leftRank, rightRank); + } + if (left.pinned != right.pinned) { + return left.pinned ? -1 : 1; + } + return compareBySortMode(left, right); + }); + return result; + } + + private boolean matchesSelectedCategory(FavoriteVideo video) { + return ALL_CATEGORY_ID.equals(selectedCategoryId) || video.categoryIds.contains(selectedCategoryId); + } + + private int searchRank(FavoriteVideo video, String normalizedQuery) { + if (containsNormalized(video.alias, normalizedQuery)) { + return 0; + } + if (containsNormalized(video.title, normalizedQuery) + || containsNormalized(video.description, normalizedQuery) + || containsNormalized(video.author, normalizedQuery) + || containsNormalized(video.memo, normalizedQuery) + || containsNormalized(categoryNamesForVideo(video), normalizedQuery)) { + return 1; + } + return 3; + } + + private int compareBySortMode(FavoriteVideo left, FavoriteVideo right) { + if (sortMode == SORT_OLDEST) { + return Long.compare(left.addedAt, right.addedAt); + } + if (sortMode == SORT_TITLE) { + String leftTitle = normalizeForSearch(emptyFallback(left.title, "")); + String rightTitle = normalizeForSearch(emptyFallback(right.title, "")); + int titleCompare = leftTitle.compareTo(rightTitle); + if (titleCompare != 0) { + return titleCompare; + } + return Long.compare(right.addedAt, left.addedAt); + } + if (sortMode == SORT_RECENT) { + int recentCompare = Long.compare(right.lastOpenedAt, left.lastOpenedAt); + if (recentCompare != 0) { + return recentCompare; + } + } + return Long.compare(right.addedAt, left.addedAt); + } + + private boolean containsNormalized(String value, String normalizedQuery) { + return value != null && normalizeForSearch(value).contains(normalizedQuery); + } + + private int categoryVideoCount(String categoryId) { + int count = 0; + for (FavoriteVideo video : videos) { + if (video.categoryIds.contains(categoryId)) { + count++; + } + } + return count; + } + + private String currentSearchQuery() { + return searchInput == null ? "" : searchInput.getText().toString().trim(); + } + + private void toggleSelectedVideo(String videoId) { + if (selectedVideoIds.contains(videoId)) { + selectedVideoIds.remove(videoId); + } else { + selectedVideoIds.add(videoId); + } + refreshUi(); + } + + private View createVideoCard(FavoriteVideo video) { + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.HORIZONTAL); + card.setGravity(Gravity.CENTER_VERTICAL); + card.setPadding(dp(12), dp(12), dp(12), dp(12)); + card.setBackground(cardBackground()); + card.setMinimumHeight(dp(112)); + card.setClickable(true); + card.setOnClickListener(view -> { + if (selectionMode) { + toggleSelectedVideo(video.id); + } else { + openVideo(video); + } + }); + if (!selectionMode) { + card.setOnLongClickListener(view -> { + showVideoEditDialog(video); + return true; + }); + } + card.setElevation(dp(1)); + + LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + cardParams.setMargins(0, dp(7), 0, dp(7)); + card.setLayoutParams(cardParams); + + if (selectionMode) { + CheckBox selectBox = new CheckBox(this); + selectBox.setChecked(selectedVideoIds.contains(video.id)); + selectBox.setOnClickListener(view -> toggleSelectedVideo(video.id)); + LinearLayout.LayoutParams selectParams = new LinearLayout.LayoutParams(dp(42), dp(74)); + card.addView(selectBox, selectParams); + } + + ImageView thumbnail = new ImageView(this); + thumbnail.setScaleType(ImageView.ScaleType.CENTER_CROP); + thumbnail.setBackgroundColor(palette.thumbnailPlaceholder); + thumbnail.setContentDescription(emptyFallback(video.title, "YouTube thumbnail")); + thumbnail.setTag(video.id); + LinearLayout.LayoutParams thumbnailParams = new LinearLayout.LayoutParams(dp(132), dp(74)); + card.addView(thumbnail, thumbnailParams); + loadThumbnail(video, thumbnail); + + LinearLayout textColumn = new LinearLayout(this); + textColumn.setOrientation(LinearLayout.VERTICAL); + textColumn.setPadding(dp(12), 0, 0, 0); + card.addView(textColumn, new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + )); + + TextView videoTitle = new TextView(this); + videoTitle.setText(highlightSearchText(emptyFallback(video.title, "제목 없음"))); + videoTitle.setTextColor(palette.textPrimary); + videoTitle.setTextSize(15); + videoTitle.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + videoTitle.setMaxLines(2); + videoTitle.setEllipsize(TextUtils.TruncateAt.END); + textColumn.addView(videoTitle); + + if (!TextUtils.isEmpty(video.alias)) { + TextView alias = new TextView(this); + alias.setText(highlightSearchText(video.alias)); + alias.setTextColor(palette.accent); + alias.setTextSize(12); + alias.setSingleLine(true); + alias.setEllipsize(TextUtils.TruncateAt.END); + alias.setPadding(dp(8), dp(3), dp(8), dp(3)); + alias.setBackground(chipBackground()); + LinearLayout.LayoutParams aliasParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + aliasParams.topMargin = dp(6); + textColumn.addView(alias, aliasParams); + } + + if (!TextUtils.isEmpty(video.memo)) { + TextView memoView = new TextView(this); + memoView.setText(highlightSearchText(video.memo)); + memoView.setTextColor(palette.textSecondary); + memoView.setTextSize(12); + memoView.setMaxLines(2); + memoView.setEllipsize(TextUtils.TruncateAt.END); + LinearLayout.LayoutParams memoParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + memoParams.topMargin = dp(5); + textColumn.addView(memoView, memoParams); + } + + String categoryText = categoryNamesForVideo(video); + if (!TextUtils.isEmpty(categoryText)) { + TextView categoryView = new TextView(this); + categoryView.setText(highlightSearchText(categoryText)); + categoryView.setTextColor(palette.accent); + categoryView.setTextSize(12); + categoryView.setSingleLine(true); + categoryView.setEllipsize(TextUtils.TruncateAt.END); + LinearLayout.LayoutParams categoryParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + categoryParams.topMargin = dp(5); + textColumn.addView(categoryView, categoryParams); + } + + String details = buildDetails(video); + if (!details.isEmpty()) { + TextView subtitle = new TextView(this); + subtitle.setText(highlightSearchText(details)); + subtitle.setTextColor(palette.textSecondary); + subtitle.setTextSize(12); + subtitle.setMaxLines(2); + subtitle.setEllipsize(TextUtils.TruncateAt.END); + LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + subtitleParams.topMargin = dp(5); + textColumn.addView(subtitle, subtitleParams); + } + + if (!selectionMode) { + Button pinButton = createSmallButton(video.pinned ? "★" : "☆"); + pinButton.setTextColor(video.pinned ? palette.accent : palette.textSecondary); + pinButton.setTextSize(20); + pinButton.setContentDescription(video.pinned ? "고정 해제" : "상단 고정"); + pinButton.setBackgroundColor(Color.TRANSPARENT); + pinButton.setOnClickListener(view -> { + video.pinned = !video.pinned; + saveVideos(); + renderList(); + }); + LinearLayout.LayoutParams pinParams = new LinearLayout.LayoutParams(dp(42), dp(74)); + pinParams.leftMargin = dp(6); + card.addView(pinButton, pinParams); + } + + return card; + } + + private String buildDetails(FavoriteVideo video) { + ArrayList parts = new ArrayList<>(); + if (!TextUtils.isEmpty(video.author)) { + parts.add(video.author); + } + if (!TextUtils.isEmpty(video.description)) { + parts.add(video.description); + } + return TextUtils.join(" · ", parts); + } + + private String categoryNamesForVideo(FavoriteVideo video) { + ArrayList names = new ArrayList<>(); + for (Category category : categories) { + if (video.categoryIds.contains(category.id)) { + names.add(category.name); + } + } + return TextUtils.join(" · ", names); + } + + private CharSequence highlightSearchText(String value) { + if (TextUtils.isEmpty(value)) { + return ""; + } + String query = currentSearchQuery(); + if (TextUtils.isEmpty(query)) { + return value; + } + + String lowerValue = value.toLowerCase(Locale.ROOT); + String lowerQuery = query.toLowerCase(Locale.ROOT); + int start = lowerValue.indexOf(lowerQuery); + if (start < 0) { + return value; + } + + SpannableString spannable = new SpannableString(value); + int highlightColor = palette.dark ? Color.rgb(88, 72, 28) : Color.rgb(255, 236, 157); + int textColor = palette.dark ? Color.WHITE : Color.rgb(31, 35, 44); + while (start >= 0) { + int end = start + lowerQuery.length(); + spannable.setSpan(new BackgroundColorSpan(highlightColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan(new ForegroundColorSpan(textColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + start = lowerValue.indexOf(lowerQuery, end); + } + return spannable; + } + + private void showVideoEditDialog(FavoriteVideo video) { + ScrollView scrollView = new ScrollView(this); + LinearLayout content = new LinearLayout(this); + content.setOrientation(LinearLayout.VERTICAL); + content.setPadding(dp(20), dp(8), dp(20), 0); + scrollView.addView(content, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + TextView aliasLabel = dialogLabel("찾기용 이름"); + content.addView(aliasLabel); + + EditText aliasInput = new EditText(this); + aliasInput.setSingleLine(false); + aliasInput.setMinLines(1); + aliasInput.setMaxLines(3); + aliasInput.setText(video.alias); + aliasInput.setHint("예: 순두부, 자취요리, 저녁메뉴"); + aliasInput.setSelectAllOnFocus(false); + aliasInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + aliasInput.setSelection(aliasInput.getText().length()); + content.addView(aliasInput, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + TextView memoLabel = dialogLabel("메모"); + LinearLayout.LayoutParams memoLabelParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + memoLabelParams.topMargin = dp(16); + content.addView(memoLabel, memoLabelParams); + + EditText memoInput = new EditText(this); + memoInput.setSingleLine(false); + memoInput.setMinLines(2); + memoInput.setMaxLines(5); + memoInput.setText(video.memo); + memoInput.setHint("예: 3분 20초부터 보기"); + memoInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + content.addView(memoInput, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + CheckBox pinnedCheckBox = new CheckBox(this); + pinnedCheckBox.setText("상단에 고정"); + pinnedCheckBox.setTextSize(15); + pinnedCheckBox.setChecked(video.pinned); + pinnedCheckBox.setPadding(0, dp(8), 0, dp(4)); + content.addView(pinnedCheckBox); + + Button refreshButton = createToolbarButton("제목 새로고침"); + refreshButton.setOnClickListener(view -> { + fetchMetadata(video, true); + Toast.makeText(this, "제목 정보를 다시 불러옵니다.", Toast.LENGTH_SHORT).show(); + }); + LinearLayout.LayoutParams refreshParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(40) + ); + refreshParams.topMargin = dp(8); + content.addView(refreshButton, refreshParams); + + TextView categoryLabel = dialogLabel("분류"); + LinearLayout.LayoutParams categoryLabelParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + categoryLabelParams.topMargin = dp(16); + content.addView(categoryLabel, categoryLabelParams); + + ArrayList categoryChecks = new ArrayList<>(); + ArrayList suggestedCategoryIds = suggestCategoryIdsForVideo(video); + if (!suggestedCategoryIds.isEmpty()) { + Button applySuggestionButton = createToolbarButton("추천 분류 적용"); + applySuggestionButton.setOnClickListener(view -> { + for (CheckBox checkBox : categoryChecks) { + if (suggestedCategoryIds.contains((String) checkBox.getTag())) { + checkBox.setChecked(true); + } + } + }); + LinearLayout.LayoutParams suggestionParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(40) + ); + suggestionParams.topMargin = dp(8); + content.addView(applySuggestionButton, suggestionParams); + } + if (categories.isEmpty()) { + TextView empty = new TextView(this); + empty.setText("등록된 분류가 없습니다."); + empty.setTextSize(14); + empty.setPadding(0, dp(8), 0, dp(8)); + content.addView(empty); + } else { + for (Category category : categories) { + CheckBox checkBox = new CheckBox(this); + checkBox.setText(suggestedCategoryIds.contains(category.id) + ? category.name + " (추천)" + : category.name); + checkBox.setTextSize(15); + checkBox.setChecked(video.categoryIds.contains(category.id)); + checkBox.setPadding(0, dp(4), 0, dp(4)); + checkBox.setTag(category.id); + categoryChecks.add(checkBox); + content.addView(checkBox); + } + } + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle("영상 편집") + .setView(scrollView) + .setPositiveButton("저장", (dialogInterface, which) -> { + video.alias = aliasInput.getText().toString().trim(); + video.memo = memoInput.getText().toString().trim(); + video.pinned = pinnedCheckBox.isChecked(); + video.categoryIds.clear(); + for (CheckBox checkBox : categoryChecks) { + if (checkBox.isChecked()) { + video.categoryIds.add((String) checkBox.getTag()); + } + } + saveVideos(); + renderList(); + }) + .setNegativeButton("취소", null) + .setNeutralButton("영상 삭제", null) + .create(); + dialog.setOnShowListener(dialogInterface -> dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + .setOnClickListener(view -> confirmDelete(video, dialog))); + dialog.show(); + } + + private void confirmDelete(FavoriteVideo video, AlertDialog editDialog) { + new AlertDialog.Builder(this) + .setTitle("삭제할까요?") + .setMessage(emptyFallback(video.title, video.url)) + .setPositiveButton("삭제", (dialogInterface, which) -> { + videos.remove(video); + saveVideos(); + editDialog.dismiss(); + renderList(); + }) + .setNegativeButton("취소", null) + .show(); + } + + private void confirmDeleteSelectedVideos() { + if (selectedVideoIds.isEmpty()) { + Toast.makeText(this, "선택된 영상이 없습니다.", Toast.LENGTH_SHORT).show(); + return; + } + + new AlertDialog.Builder(this) + .setTitle("선택한 영상을 삭제할까요?") + .setMessage(String.format(Locale.ROOT, "%d개 영상이 삭제됩니다.", selectedVideoIds.size())) + .setPositiveButton("삭제", (dialogInterface, which) -> { + for (int i = videos.size() - 1; i >= 0; i--) { + if (selectedVideoIds.contains(videos.get(i).id)) { + videos.remove(i); + } + } + saveVideos(); + selectionMode = false; + selectedVideoIds.clear(); + refreshUi(); + }) + .setNegativeButton("취소", null) + .show(); + } + + private void showCategoryDialog(Category category) { + boolean editing = category != null; + EditText input = new EditText(this); + input.setSingleLine(true); + input.setHint("분류 이름"); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setText(editing ? category.name : ""); + input.setSelectAllOnFocus(true); + input.setPadding(dp(20), 0, dp(20), 0); + + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(editing ? "분류 수정" : "분류 추가") + .setView(input) + .setPositiveButton("저장", null) + .setNegativeButton("취소", null); + if (editing) { + builder.setNeutralButton("삭제", null); + } + + AlertDialog dialog = builder.create(); + dialog.setOnShowListener(dialogInterface -> { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> { + String name = input.getText().toString().trim(); + if (TextUtils.isEmpty(name)) { + Toast.makeText(this, "분류 이름을 입력하세요.", Toast.LENGTH_SHORT).show(); + return; + } + if (isDuplicateCategoryName(name, editing ? category.id : null)) { + Toast.makeText(this, "이미 있는 분류입니다.", Toast.LENGTH_SHORT).show(); + return; + } + if (editing) { + category.name = name; + } else { + categories.add(new Category(UUID.randomUUID().toString(), name)); + } + saveCategories(); + dialog.dismiss(); + refreshUi(); + }); + if (editing) { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + .setOnClickListener(view -> confirmDeleteCategory(category, dialog)); + } + }); + dialog.show(); + } + + private void confirmDeleteCategory(Category category, AlertDialog editDialog) { + new AlertDialog.Builder(this) + .setTitle("분류를 삭제할까요?") + .setMessage(category.name) + .setPositiveButton("삭제", (dialogInterface, which) -> { + categories.remove(category); + for (FavoriteVideo video : videos) { + video.categoryIds.remove(category.id); + } + if (category.id.equals(selectedCategoryId)) { + selectedCategoryId = ALL_CATEGORY_ID; + } + saveCategories(); + saveVideos(); + editDialog.dismiss(); + refreshUi(); + }) + .setNegativeButton("취소", null) + .show(); + } + + private boolean isDuplicateCategoryName(String name, String editingCategoryId) { + String normalized = normalizeForSearch(name); + for (Category category : categories) { + if (!category.id.equals(editingCategoryId) && normalizeForSearch(category.name).equals(normalized)) { + return true; + } + } + return false; + } + + private void showSortDialog() { + String[] labels = {"최신순", "오래된순", "제목순", "최근 본 순"}; + int checked = sortMode; + if (checked < SORT_LATEST || checked > SORT_RECENT) { + checked = SORT_LATEST; + } + new AlertDialog.Builder(this) + .setTitle("정렬") + .setSingleChoiceItems(labels, checked, (dialogInterface, which) -> { + sortMode = which; + saveAppearance(); + dialogInterface.dismiss(); + refreshUi(); + }) + .setNegativeButton("취소", null) + .show(); + } + + private String sortModeName() { + if (sortMode == SORT_OLDEST) { + return "오래된순"; + } + if (sortMode == SORT_TITLE) { + return "제목순"; + } + if (sortMode == SORT_RECENT) { + return "최근 본 순"; + } + return "최신순"; + } + + private void showManageDialog() { + String[] actions = {"분류 순서 변경", "전체 제목 새로고침", "백업 내보내기", "백업 가져오기"}; + new AlertDialog.Builder(this) + .setTitle("관리") + .setItems(actions, (dialogInterface, which) -> { + if (which == 0) { + showCategoryReorderDialog(); + } else if (which == 1) { + refreshAllMetadata(); + } else if (which == 2) { + startExportBackup(); + } else if (which == 3) { + startImportBackup(); + } + }) + .show(); + } + + private void showCategoryReorderDialog() { + if (categories.size() < 2) { + Toast.makeText(this, "순서를 바꿀 분류가 부족합니다.", Toast.LENGTH_SHORT).show(); + return; + } + + ArrayList workingCategories = new ArrayList<>(categories); + LinearLayout content = new LinearLayout(this); + content.setOrientation(LinearLayout.VERTICAL); + content.setPadding(dp(16), dp(8), dp(16), 0); + renderReorderRows(content, workingCategories); + + new AlertDialog.Builder(this) + .setTitle("분류 순서 변경") + .setView(content) + .setPositiveButton("저장", (dialogInterface, which) -> { + categories.clear(); + categories.addAll(workingCategories); + saveCategories(); + refreshUi(); + }) + .setNegativeButton("취소", null) + .show(); + } + + private void renderReorderRows(LinearLayout content, ArrayList workingCategories) { + content.removeAllViews(); + TextView guide = new TextView(this); + guide.setText("분류를 길게 누른 뒤 원하는 위치로 끌어 놓으세요."); + guide.setTextColor(Color.rgb(80, 87, 102)); + guide.setTextSize(13); + guide.setPadding(0, 0, 0, dp(8)); + content.addView(guide); + + for (Category category : workingCategories) { + TextView row = new TextView(this); + row.setText(category.name); + row.setTextColor(palette.textPrimary); + row.setTextSize(16); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(dp(14), 0, dp(14), 0); + row.setBackground(cardBackground()); + row.setTag(category.id); + row.setOnLongClickListener(view -> { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + view.startDragAndDrop(null, new View.DragShadowBuilder(view), view.getTag(), 0); + } else { + view.startDrag(null, new View.DragShadowBuilder(view), view.getTag(), 0); + } + return true; + }); + row.setOnDragListener((view, event) -> { + if (event.getAction() == DragEvent.ACTION_DROP) { + Object dragged = event.getLocalState(); + if (dragged instanceof String) { + moveCategoryBefore(workingCategories, (String) dragged, (String) view.getTag()); + renderReorderRows(content, workingCategories); + } + return true; + } + return true; + }); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(48) + ); + params.bottomMargin = dp(8); + content.addView(row, params); + } + } + + private void moveCategoryBefore(ArrayList workingCategories, String draggedId, String targetId) { + if (draggedId.equals(targetId)) { + return; + } + Category draggedCategory = null; + for (int i = 0; i < workingCategories.size(); i++) { + if (workingCategories.get(i).id.equals(draggedId)) { + draggedCategory = workingCategories.remove(i); + break; + } + } + if (draggedCategory == null) { + return; + } + int targetIndex = workingCategories.size(); + for (int i = 0; i < workingCategories.size(); i++) { + if (workingCategories.get(i).id.equals(targetId)) { + targetIndex = i; + break; + } + } + workingCategories.add(targetIndex, draggedCategory); + } + + private void refreshAllMetadata() { + if (videos.isEmpty()) { + Toast.makeText(this, "새로고침할 영상이 없습니다.", Toast.LENGTH_SHORT).show(); + return; + } + for (FavoriteVideo video : videos) { + fetchMetadata(video, true); + } + Toast.makeText(this, "전체 제목 정보를 다시 불러옵니다.", Toast.LENGTH_SHORT).show(); + } + + private void showBulkEditDialog() { + if (selectedVideoIds.isEmpty()) { + Toast.makeText(this, "선택된 영상이 없습니다.", Toast.LENGTH_SHORT).show(); + return; + } + + ScrollView scrollView = new ScrollView(this); + LinearLayout content = new LinearLayout(this); + content.setOrientation(LinearLayout.VERTICAL); + content.setPadding(dp(20), dp(8), dp(20), 0); + scrollView.addView(content, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + RadioGroup actionGroup = new RadioGroup(this); + actionGroup.setOrientation(RadioGroup.VERTICAL); + int addActionId = View.generateViewId(); + int removeActionId = View.generateViewId(); + RadioButton add = themeRadioButton("선택한 분류 추가", addActionId); + RadioButton remove = themeRadioButton("선택한 분류 제거", removeActionId); + actionGroup.addView(add); + actionGroup.addView(remove); + actionGroup.check(addActionId); + content.addView(actionGroup); + + TextView categoryLabel = dialogLabel("분류"); + LinearLayout.LayoutParams categoryLabelParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + categoryLabelParams.topMargin = dp(16); + content.addView(categoryLabel, categoryLabelParams); + + ArrayList checks = new ArrayList<>(); + for (Category category : categories) { + CheckBox checkBox = new CheckBox(this); + checkBox.setText(category.name); + checkBox.setTextSize(15); + checkBox.setPadding(0, dp(4), 0, dp(4)); + checkBox.setTag(category.id); + checks.add(checkBox); + content.addView(checkBox); + } + + new AlertDialog.Builder(this) + .setTitle("일괄 분류 편집") + .setView(scrollView) + .setPositiveButton("적용", (dialogInterface, which) -> { + ArrayList targetCategoryIds = new ArrayList<>(); + for (CheckBox checkBox : checks) { + if (checkBox.isChecked()) { + targetCategoryIds.add((String) checkBox.getTag()); + } + } + if (targetCategoryIds.isEmpty()) { + Toast.makeText(this, "분류를 선택하세요.", Toast.LENGTH_SHORT).show(); + return; + } + boolean addMode = actionGroup.getCheckedRadioButtonId() == addActionId; + for (FavoriteVideo video : videos) { + if (!selectedVideoIds.contains(video.id)) { + continue; + } + if (addMode) { + for (String categoryId : targetCategoryIds) { + if (!video.categoryIds.contains(categoryId)) { + video.categoryIds.add(categoryId); + } + } + } else { + for (String categoryId : targetCategoryIds) { + video.categoryIds.remove(categoryId); + } + } + } + saveVideos(); + selectionMode = false; + selectedVideoIds.clear(); + refreshUi(); + }) + .setNegativeButton("취소", null) + .show(); + } + + private void showThemeDialog() { + LinearLayout content = new LinearLayout(this); + content.setOrientation(LinearLayout.VERTICAL); + int sidePadding = dp(20); + content.setPadding(sidePadding, dp(8), sidePadding, 0); + + TextView modeLabel = dialogLabel("화면 모드"); + content.addView(modeLabel); + + RadioGroup modeGroup = new RadioGroup(this); + modeGroup.setOrientation(RadioGroup.VERTICAL); + content.addView(modeGroup, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + RadioButton system = themeRadioButton("시스템 설정 사용", THEME_SYSTEM); + RadioButton light = themeRadioButton("라이트", THEME_LIGHT); + RadioButton dark = themeRadioButton("다크", THEME_DARK); + modeGroup.addView(system); + modeGroup.addView(light); + modeGroup.addView(dark); + modeGroup.check(themeMode); + + TextView colorLabel = dialogLabel("포인트 색상"); + LinearLayout.LayoutParams colorLabelParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + colorLabelParams.topMargin = dp(18); + content.addView(colorLabel, colorLabelParams); + + LinearLayout colorRow = new LinearLayout(this); + colorRow.setOrientation(LinearLayout.HORIZONTAL); + colorRow.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams colorRowParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + colorRowParams.topMargin = dp(8); + content.addView(colorRow, colorRowParams); + + AlertDialog dialog = new AlertDialog.Builder(this) + .setTitle("테마") + .setView(content) + .setPositiveButton("닫기", null) + .create(); + + modeGroup.setOnCheckedChangeListener((group, checkedId) -> { + themeMode = checkedId; + saveAppearance(); + refreshUi(); + }); + + for (int i = 0; i < ACCENT_COLORS.length; i++) { + Button swatch = new Button(this); + swatch.setText(i == accentIndex ? "✓" : ""); + swatch.setTextColor(Color.WHITE); + swatch.setTextSize(16); + swatch.setAllCaps(false); + swatch.setContentDescription(ACCENT_NAMES[i]); + swatch.setBackground(swatchBackground(ACCENT_COLORS[i], i == accentIndex)); + int selectedIndex = i; + swatch.setOnClickListener(view -> { + accentIndex = selectedIndex; + saveAppearance(); + dialog.dismiss(); + refreshUi(); + }); + LinearLayout.LayoutParams swatchParams = new LinearLayout.LayoutParams(dp(42), dp(42)); + if (i > 0) { + swatchParams.leftMargin = dp(10); + } + colorRow.addView(swatch, swatchParams); + } + + dialog.show(); + } + + private TextView dialogLabel(String text) { + TextView label = new TextView(this); + label.setText(text); + label.setTextColor(Color.rgb(80, 87, 102)); + label.setTextSize(13); + label.setTypeface(Typeface.DEFAULT, Typeface.BOLD); + return label; + } + + private RadioButton themeRadioButton(String text, int id) { + RadioButton button = new RadioButton(this); + button.setText(text); + button.setTextSize(15); + button.setId(id); + button.setPadding(0, dp(4), 0, dp(4)); + return button; + } + + private void refreshUi() { + String pendingUrl = urlInput == null ? "" : urlInput.getText().toString(); + String pendingSearch = searchInput == null ? "" : searchInput.getText().toString(); + buildUi(pendingUrl, pendingSearch); + renderList(); + } + + private void openVideo(FavoriteVideo video) { + video.lastOpenedAt = System.currentTimeMillis(); + saveVideos(); + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(video.url))); + } catch (Exception exception) { + Toast.makeText(this, "영상을 열 수 없습니다.", Toast.LENGTH_SHORT).show(); + } + } + + private void fetchMetadata(FavoriteVideo video) { + fetchMetadata(video, false); + } + + private void fetchMetadata(FavoriteVideo video, boolean forceTitleRefresh) { + executor.execute(() -> { + VideoMetadata metadata = new VideoMetadata(); + try { + String canonicalUrl = "https://www.youtube.com/watch?v=" + video.videoId; + String oembedUrl = "https://www.youtube.com/oembed?format=json&url=" + + URLEncoder.encode(canonicalUrl, StandardCharsets.UTF_8.name()); + String oembedJson = fetchText(oembedUrl); + JSONObject object = new JSONObject(oembedJson); + metadata.title = object.optString("title", ""); + metadata.author = object.optString("author_name", ""); + } catch (Exception ignored) { + } + + try { + String html = fetchText("https://www.youtube.com/watch?v=" + video.videoId); + String metaTitle = extractMetaContent(html, "og:title"); + String metaDescription = extractMetaContent(html, "description"); + if (TextUtils.isEmpty(metaDescription)) { + metaDescription = extractMetaContent(html, "og:description"); + } + if (TextUtils.isEmpty(metadata.title)) { + metadata.title = metaTitle; + } + metadata.description = metaDescription; + } catch (Exception ignored) { + } + + mainHandler.post(() -> { + boolean changed = false; + if (!TextUtils.isEmpty(metadata.title) && (forceTitleRefresh || LOADING_TITLE.equals(video.title))) { + video.title = metadata.title; + changed = true; + } + if (!TextUtils.isEmpty(metadata.author)) { + video.author = metadata.author; + changed = true; + } + if (!TextUtils.isEmpty(metadata.description)) { + video.description = metadata.description; + changed = true; + } + if (video.categoryIds.isEmpty() && applySuggestedCategories(video)) { + changed = true; + } + if (changed) { + saveVideos(); + renderList(); + } + }); + }); + } + + private boolean applySuggestedCategories(FavoriteVideo video) { + ArrayList suggestions = suggestCategoryIdsForVideo(video); + boolean changed = false; + for (String categoryId : suggestions) { + if (!video.categoryIds.contains(categoryId)) { + video.categoryIds.add(categoryId); + changed = true; + } + } + return changed; + } + + private ArrayList suggestCategoryIdsForVideo(FavoriteVideo video) { + ArrayList suggestions = new ArrayList<>(); + String haystack = normalizeForSearch(TextUtils.join(" ", new String[]{ + emptyFallback(video.title, ""), + emptyFallback(video.description, ""), + emptyFallback(video.author, ""), + emptyFallback(video.alias, ""), + emptyFallback(video.memo, "") + })); + + if (TextUtils.isEmpty(haystack)) { + return suggestions; + } + + for (Category category : categories) { + if (containsAnyKeyword(haystack, categoryKeywords(category))) { + suggestions.add(category.id); + } + } + return suggestions; + } + + private ArrayList categoryKeywords(Category category) { + ArrayList keywords = new ArrayList<>(); + keywords.add(category.name); + String normalizedName = normalizeForSearch(category.name); + if (normalizedName.contains("음식") || normalizedName.contains("요리") || category.id.equals("default_food")) { + Collections.addAll(keywords, "음식", "요리", "레시피", "밥", "반찬", "찌개", "국", "라면", "순두부", "김치", "고기", "맛집", "먹방"); + } + if (normalizedName.contains("운동") || normalizedName.contains("헬스") || category.id.equals("default_exercise")) { + Collections.addAll(keywords, "운동", "헬스", "근력", "루틴", "스트레칭", "유산소", "요가", "필라테스", "다이어트", "홈트"); + } + if (normalizedName.contains("게임") || category.id.equals("default_game")) { + Collections.addAll(keywords, "게임", "공략", "플레이", "스팀", "콘솔", "모바일게임", "롤", "배그", "마인크래프트"); + } + if (normalizedName.contains("유머") || normalizedName.contains("웃") || category.id.equals("default_humor")) { + Collections.addAll(keywords, "유머", "웃긴", "개그", "예능", "밈", "레전드", "몰카", "웃음"); + } + if (normalizedName.contains("뉴스") || normalizedName.contains("시사") || category.id.equals("default_news")) { + Collections.addAll(keywords, "뉴스", "속보", "정치", "경제", "사회", "국제", "사건", "브리핑", "시사"); + } + return keywords; + } + + private boolean containsAnyKeyword(String normalizedHaystack, ArrayList keywords) { + for (String keyword : keywords) { + String normalizedKeyword = normalizeForSearch(keyword); + if (!TextUtils.isEmpty(normalizedKeyword) && normalizedHaystack.contains(normalizedKeyword)) { + return true; + } + } + return false; + } + + private String fetchText(String urlString) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection(); + connection.setConnectTimeout(8000); + connection.setReadTimeout(8000); + connection.setRequestProperty("User-Agent", "Mozilla/5.0"); + connection.setRequestProperty("Accept-Language", Locale.getDefault().toLanguageTag()); + try (InputStream stream = new BufferedInputStream(connection.getInputStream()); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line).append('\n'); + } + return builder.toString(); + } finally { + connection.disconnect(); + } + } + + private String extractMetaContent(String html, String name) { + if (TextUtils.isEmpty(html)) { + return ""; + } + + Pattern tagPattern = Pattern.compile("]+(?:name|property)=[\"']" + + Pattern.quote(name) + + "[\"'][^>]*>", Pattern.CASE_INSENSITIVE); + Matcher tagMatcher = tagPattern.matcher(html); + if (!tagMatcher.find()) { + return ""; + } + + Matcher contentMatcher = Pattern.compile("content=[\"']([^\"']*)[\"']", Pattern.CASE_INSENSITIVE) + .matcher(tagMatcher.group()); + if (!contentMatcher.find()) { + return ""; + } + return decodeHtml(contentMatcher.group(1)).trim(); + } + + private String decodeHtml(String value) { + if (TextUtils.isEmpty(value)) { + return ""; + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + return Html.fromHtml(value, Html.FROM_HTML_MODE_LEGACY).toString(); + } + return Html.fromHtml(value).toString(); + } + + private void loadThumbnail(FavoriteVideo video, ImageView imageView) { + Bitmap cached = thumbnailCache.get(video.thumbnailUrl); + if (cached != null) { + imageView.setImageBitmap(cached); + return; + } + + executor.execute(() -> { + Bitmap bitmap = loadThumbnailFromDisk(video.videoId); + HttpURLConnection connection = null; + if (bitmap == null) { + try { + connection = (HttpURLConnection) new URL(video.thumbnailUrl).openConnection(); + connection.setConnectTimeout(8000); + connection.setReadTimeout(8000); + try (InputStream stream = connection.getInputStream()) { + bitmap = BitmapFactory.decodeStream(stream); + } + if (bitmap != null) { + saveThumbnailToDisk(video.videoId, bitmap); + } + } catch (IOException ignored) { + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + if (bitmap != null) { + thumbnailCache.put(video.thumbnailUrl, bitmap); + Bitmap finalBitmap = bitmap; + mainHandler.post(() -> { + Object tag = imageView.getTag(); + if (video.id.equals(tag)) { + imageView.setImageBitmap(finalBitmap); + } + }); + } + }); + } + + private Bitmap loadThumbnailFromDisk(String videoId) { + File file = thumbnailCacheFile(videoId); + if (!file.exists()) { + return null; + } + return BitmapFactory.decodeFile(file.getAbsolutePath()); + } + + private void saveThumbnailToDisk(String videoId, Bitmap bitmap) { + File file = thumbnailCacheFile(videoId); + File parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + return; + } + try (FileOutputStream stream = new FileOutputStream(file)) { + bitmap.compress(Bitmap.CompressFormat.JPEG, 88, stream); + } catch (IOException ignored) { + } + } + + private File thumbnailCacheFile(String videoId) { + File directory = new File(getCacheDir(), "youtube_thumbnails"); + return new File(directory, videoId + ".jpg"); + } + + private String findYoutubeUrl(String text) { + if (TextUtils.isEmpty(text)) { + return null; + } + + Pattern urlPattern = Pattern.compile("(https?://\\S+|www\\.youtube\\.com/\\S+|youtube\\.com/\\S+|youtu\\.be/\\S+)", + Pattern.CASE_INSENSITIVE); + Matcher matcher = urlPattern.matcher(text); + while (matcher.find()) { + String candidate = trimSharedUrl(matcher.group(1)); + String normalized = normalizeUrl(candidate); + if (extractVideoId(normalized) != null) { + return candidate; + } + } + return null; + } + + private String trimSharedUrl(String value) { + String trimmed = value.trim(); + while (trimmed.endsWith(")") || trimmed.endsWith("]") || trimmed.endsWith("}") || trimmed.endsWith(".") + || trimmed.endsWith(",") || trimmed.endsWith(";")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private String normalizeUrl(String rawUrl) { + String trimmed = rawUrl.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return "https://" + trimmed; + } + return trimmed; + } + + private String extractVideoId(String url) { + try { + Uri uri = Uri.parse(url); + String host = uri.getHost(); + if (host == null) { + return null; + } + host = host.toLowerCase(Locale.ROOT); + List segments = uri.getPathSegments(); + + if (host.equals("youtu.be") && !segments.isEmpty()) { + return cleanVideoId(segments.get(0)); + } + + if (!host.endsWith("youtube.com") && !host.endsWith("youtube-nocookie.com")) { + return null; + } + + String queryId = uri.getQueryParameter("v"); + if (!TextUtils.isEmpty(queryId)) { + return cleanVideoId(queryId); + } + + if (segments.size() >= 2) { + String first = segments.get(0); + if ("shorts".equals(first) || "embed".equals(first) || "live".equals(first)) { + return cleanVideoId(segments.get(1)); + } + } + } catch (Exception ignored) { + } + return null; + } + + private String cleanVideoId(String value) { + if (TextUtils.isEmpty(value)) { + return null; + } + Matcher matcher = Pattern.compile("^[A-Za-z0-9_-]{6,}$").matcher(value); + if (!matcher.find()) { + return null; + } + return value; + } + + private String normalizeForSearch(String value) { + if (value == null) { + return ""; + } + return value.toLowerCase(Locale.ROOT).trim(); + } + + private String emptyFallback(String value, String fallback) { + return TextUtils.isEmpty(value) ? fallback : value; + } + + private GradientDrawable inputBackground() { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(palette.input); + drawable.setCornerRadius(dp(8)); + drawable.setStroke(dp(1), palette.border); + return drawable; + } + + private GradientDrawable buttonBackground(int color) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(color); + drawable.setCornerRadius(dp(8)); + return drawable; + } + + private GradientDrawable categoryTabBackground(boolean selected) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(selected ? palette.accent : palette.surface); + drawable.setCornerRadius(dp(8)); + drawable.setStroke(dp(1), selected ? palette.accent : palette.border); + return drawable; + } + + private GradientDrawable cardBackground() { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(palette.surface); + drawable.setCornerRadius(dp(8)); + drawable.setStroke(dp(1), palette.border); + return drawable; + } + + private GradientDrawable chipBackground() { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(palette.chipBackground); + drawable.setCornerRadius(dp(8)); + return drawable; + } + + private GradientDrawable swatchBackground(int color, boolean selected) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(color); + drawable.setShape(GradientDrawable.RECTANGLE); + drawable.setCornerRadius(dp(8)); + drawable.setStroke(selected ? dp(3) : dp(1), selected ? Color.WHITE : Color.TRANSPARENT); + return drawable; + } + + private ThemePalette createPalette() { + boolean dark = isDarkModeActive(); + int accent = ACCENT_COLORS[Math.max(0, Math.min(accentIndex, ACCENT_COLORS.length - 1))]; + if (dark) { + return new ThemePalette( + true, + accent, + Color.rgb(18, 20, 24), + Color.rgb(31, 34, 40), + Color.rgb(235, 238, 244), + Color.rgb(165, 174, 190), + Color.rgb(57, 63, 74), + Color.rgb(39, 43, 51), + Color.rgb(33, 44, 67), + Color.rgb(48, 54, 65) + ); + } + return new ThemePalette( + false, + accent, + Color.rgb(246, 247, 250), + Color.WHITE, + Color.rgb(31, 35, 44), + Color.rgb(94, 103, 118), + Color.rgb(217, 223, 235), + Color.WHITE, + Color.rgb(235, 241, 255), + Color.rgb(226, 231, 240) + ); + } + + private boolean isDarkModeActive() { + if (themeMode == THEME_DARK) { + return true; + } + if (themeMode == THEME_LIGHT) { + return false; + } + int nightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + return nightMode == Configuration.UI_MODE_NIGHT_YES; + } + + private void applySystemBars() { + Window window = getWindow(); + window.setStatusBarColor(palette.background); + window.setNavigationBarColor(palette.surface); + + int flags = 0; + if (!palette.dark) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + } + } + window.getDecorView().setSystemUiVisibility(flags); + } + + private int dp(int value) { + return (int) (value * getResources().getDisplayMetrics().density + 0.5f); + } + + private void loadCategories() { + categories.clear(); + SharedPreferences preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + if (!preferences.contains(CATEGORIES_KEY)) { + addDefaultCategories(); + saveCategories(); + return; + } + + String rawJson = preferences.getString(CATEGORIES_KEY, "[]"); + try { + JSONArray array = new JSONArray(rawJson); + for (int i = 0; i < array.length(); i++) { + Category category = Category.fromJson(array.getJSONObject(i)); + if (category != null && !isDuplicateCategoryName(category.name, category.id)) { + categories.add(category); + } + } + } catch (JSONException exception) { + categories.clear(); + addDefaultCategories(); + saveCategories(); + } + } + + private void addDefaultCategories() { + categories.add(new Category("default_food", "음식")); + categories.add(new Category("default_exercise", "운동")); + categories.add(new Category("default_game", "게임")); + categories.add(new Category("default_humor", "유머")); + categories.add(new Category("default_news", "뉴스")); + } + + private void saveCategories() { + JSONArray array = new JSONArray(); + for (Category category : categories) { + array.put(category.toJson()); + } + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putString(CATEGORIES_KEY, array.toString()) + .apply(); + } + + private void loadVideos() { + videos.clear(); + SharedPreferences preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + String rawJson = preferences.getString(VIDEOS_KEY, "[]"); + try { + JSONArray array = new JSONArray(rawJson); + for (int i = 0; i < array.length(); i++) { + FavoriteVideo video = FavoriteVideo.fromJson(array.getJSONObject(i)); + if (video != null) { + cleanCategoryIds(video); + videos.add(video); + } + } + Collections.sort(videos, (left, right) -> Long.compare(right.addedAt, left.addedAt)); + } catch (JSONException exception) { + preferences.edit().remove(VIDEOS_KEY).apply(); + } + } + + private void cleanCategoryIds(FavoriteVideo video) { + for (int i = video.categoryIds.size() - 1; i >= 0; i--) { + String categoryId = video.categoryIds.get(i); + if (!isKnownCategoryId(categoryId) || firstCategoryIndex(video.categoryIds, categoryId) != i) { + video.categoryIds.remove(i); + } + } + } + + private int firstCategoryIndex(ArrayList categoryIds, String categoryId) { + for (int i = 0; i < categoryIds.size(); i++) { + if (categoryIds.get(i).equals(categoryId)) { + return i; + } + } + return -1; + } + + private boolean isKnownCategoryId(String categoryId) { + for (Category category : categories) { + if (category.id.equals(categoryId)) { + return true; + } + } + return false; + } + + private void saveVideos() { + JSONArray array = new JSONArray(); + for (FavoriteVideo video : videos) { + array.put(video.toJson()); + } + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putString(VIDEOS_KEY, array.toString()) + .apply(); + } + + private void loadAppearance() { + SharedPreferences preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + int storedThemeMode = preferences.getInt(THEME_MODE_KEY, THEME_SYSTEM); + themeMode = normalizeThemeMode(storedThemeMode); + + accentIndex = preferences.getInt(ACCENT_INDEX_KEY, 0); + if (accentIndex < 0 || accentIndex >= ACCENT_COLORS.length) { + accentIndex = 0; + } + + sortMode = preferences.getInt(SORT_MODE_KEY, SORT_LATEST); + if (sortMode < SORT_LATEST || sortMode > SORT_RECENT) { + sortMode = SORT_LATEST; + } + uncategorizedOnly = preferences.getBoolean(UNCATEGORIZED_ONLY_KEY, false); + } + + private void saveAppearance() { + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putInt(THEME_MODE_KEY, themeMode) + .putInt(ACCENT_INDEX_KEY, accentIndex) + .putInt(SORT_MODE_KEY, sortMode) + .putBoolean(UNCATEGORIZED_ONLY_KEY, uncategorizedOnly) + .apply(); + } + + private void loadSearchHistory() { + searchHistory.clear(); + SharedPreferences preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + String rawJson = preferences.getString(SEARCH_HISTORY_KEY, "[]"); + try { + JSONArray array = new JSONArray(rawJson); + for (int i = 0; i < array.length() && searchHistory.size() < MAX_SEARCH_HISTORY; i++) { + String query = array.optString(i, "").trim(); + if (!TextUtils.isEmpty(query) && !searchHistory.contains(query)) { + searchHistory.add(query); + } + } + } catch (JSONException exception) { + preferences.edit().remove(SEARCH_HISTORY_KEY).apply(); + } + } + + private void saveSearchQuery(String rawQuery) { + String query = rawQuery == null ? "" : rawQuery.trim(); + if (TextUtils.isEmpty(query)) { + return; + } + searchHistory.remove(query); + searchHistory.add(0, query); + while (searchHistory.size() > MAX_SEARCH_HISTORY) { + searchHistory.remove(searchHistory.size() - 1); + } + saveSearchHistory(); + refreshUi(); + } + + private void saveSearchHistory() { + JSONArray array = new JSONArray(); + for (String query : searchHistory) { + array.put(query); + } + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putString(SEARCH_HISTORY_KEY, array.toString()) + .apply(); + } + + private void startExportBackup() { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/json"); + intent.putExtra(Intent.EXTRA_TITLE, "youtube-favorites-backup.json"); + try { + startActivityForResult(intent, REQUEST_EXPORT_BACKUP); + } catch (Exception exception) { + Toast.makeText(this, "백업 파일을 만들 수 없습니다.", Toast.LENGTH_SHORT).show(); + } + } + + private void startImportBackup() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("*/*"); + try { + startActivityForResult(intent, REQUEST_IMPORT_BACKUP); + } catch (Exception exception) { + Toast.makeText(this, "백업 파일을 열 수 없습니다.", Toast.LENGTH_SHORT).show(); + } + } + + private void exportBackup(Uri uri) { + try (OutputStream stream = getContentResolver().openOutputStream(uri); + OutputStreamWriter writer = new OutputStreamWriter(stream, StandardCharsets.UTF_8)) { + writer.write(buildBackupJson().toString(2)); + Toast.makeText(this, "백업을 내보냈습니다.", Toast.LENGTH_SHORT).show(); + } catch (Exception exception) { + Toast.makeText(this, "백업 내보내기에 실패했습니다.", Toast.LENGTH_SHORT).show(); + } + } + + private JSONObject buildBackupJson() throws JSONException { + JSONObject object = new JSONObject(); + object.put("version", 1); + + JSONArray categoryArray = new JSONArray(); + for (Category category : categories) { + categoryArray.put(category.toJson()); + } + object.put("categories", categoryArray); + + JSONArray videoArray = new JSONArray(); + for (FavoriteVideo video : videos) { + videoArray.put(video.toJson()); + } + object.put("videos", videoArray); + + object.put("themeMode", themeMode); + object.put("accentIndex", accentIndex); + object.put("sortMode", sortMode); + object.put("uncategorizedOnly", uncategorizedOnly); + JSONArray searchHistoryArray = new JSONArray(); + for (String query : searchHistory) { + searchHistoryArray.put(query); + } + object.put("searchHistory", searchHistoryArray); + return object; + } + + private void importBackup(Uri uri) { + try (InputStream stream = getContentResolver().openInputStream(uri)) { + if (stream == null) { + Toast.makeText(this, "백업 파일을 읽을 수 없습니다.", Toast.LENGTH_SHORT).show(); + return; + } + String rawJson = readText(stream); + BackupData backupData = parseBackup(rawJson); + new AlertDialog.Builder(this) + .setTitle("백업을 가져올까요?") + .setMessage("현재 저장된 영상과 분류가 백업 내용으로 교체됩니다.") + .setPositiveButton("가져오기", (dialogInterface, which) -> applyBackup(backupData)) + .setNegativeButton("취소", null) + .show(); + } catch (Exception exception) { + Toast.makeText(this, "백업 가져오기에 실패했습니다.", Toast.LENGTH_SHORT).show(); + } + } + + private String readText(InputStream stream) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line).append('\n'); + } + return builder.toString(); + } + } + + private BackupData parseBackup(String rawJson) throws JSONException { + JSONObject object = new JSONObject(rawJson); + BackupData data = new BackupData(); + + JSONArray categoryArray = object.optJSONArray("categories"); + if (categoryArray != null) { + for (int i = 0; i < categoryArray.length(); i++) { + Category category = Category.fromJson(categoryArray.getJSONObject(i)); + if (category != null && !containsCategoryName(data.categories, category.name, category.id)) { + data.categories.add(category); + } + } + } + if (data.categories.isEmpty()) { + data.categories.add(new Category("default_food", "음식")); + data.categories.add(new Category("default_exercise", "운동")); + data.categories.add(new Category("default_game", "게임")); + data.categories.add(new Category("default_humor", "유머")); + data.categories.add(new Category("default_news", "뉴스")); + } + + JSONArray videoArray = object.optJSONArray("videos"); + if (videoArray != null) { + for (int i = 0; i < videoArray.length(); i++) { + FavoriteVideo video = FavoriteVideo.fromJson(videoArray.getJSONObject(i)); + if (video != null) { + cleanCategoryIds(video, data.categories); + data.videos.add(video); + } + } + } + + data.themeMode = object.optInt("themeMode", themeMode); + data.accentIndex = object.optInt("accentIndex", accentIndex); + data.sortMode = object.optInt("sortMode", sortMode); + data.uncategorizedOnly = object.optBoolean("uncategorizedOnly", uncategorizedOnly); + JSONArray searchHistoryArray = object.optJSONArray("searchHistory"); + if (searchHistoryArray != null) { + for (int i = 0; i < searchHistoryArray.length() && data.searchHistory.size() < MAX_SEARCH_HISTORY; i++) { + String query = searchHistoryArray.optString(i, "").trim(); + if (!TextUtils.isEmpty(query) && !data.searchHistory.contains(query)) { + data.searchHistory.add(query); + } + } + } + return data; + } + + private boolean containsCategoryName(ArrayList categoryList, String name, String editingCategoryId) { + String normalized = normalizeForSearch(name); + for (Category category : categoryList) { + if (!category.id.equals(editingCategoryId) && normalizeForSearch(category.name).equals(normalized)) { + return true; + } + } + return false; + } + + private void cleanCategoryIds(FavoriteVideo video, ArrayList categoryList) { + for (int i = video.categoryIds.size() - 1; i >= 0; i--) { + String categoryId = video.categoryIds.get(i); + if (!isKnownCategoryId(categoryId, categoryList) || firstCategoryIndex(video.categoryIds, categoryId) != i) { + video.categoryIds.remove(i); + } + } + } + + private boolean isKnownCategoryId(String categoryId, ArrayList categoryList) { + for (Category category : categoryList) { + if (category.id.equals(categoryId)) { + return true; + } + } + return false; + } + + private void applyBackup(BackupData data) { + categories.clear(); + categories.addAll(data.categories); + videos.clear(); + videos.addAll(data.videos); + themeMode = normalizeThemeMode(data.themeMode); + accentIndex = data.accentIndex >= 0 && data.accentIndex < ACCENT_COLORS.length ? data.accentIndex : 0; + sortMode = data.sortMode >= SORT_LATEST && data.sortMode <= SORT_RECENT ? data.sortMode : SORT_LATEST; + uncategorizedOnly = data.uncategorizedOnly; + searchHistory.clear(); + searchHistory.addAll(data.searchHistory); + selectedCategoryId = ALL_CATEGORY_ID; + selectionMode = false; + selectedVideoIds.clear(); + saveCategories(); + saveVideos(); + saveAppearance(); + saveSearchHistory(); + refreshUi(); + Toast.makeText(this, "백업을 가져왔습니다.", Toast.LENGTH_SHORT).show(); + } + + private int normalizeThemeMode(int value) { + if (value == 0 || value == THEME_SYSTEM) { + return THEME_SYSTEM; + } + if (value == 1 || value == THEME_LIGHT) { + return THEME_LIGHT; + } + if (value == 2 || value == THEME_DARK) { + return THEME_DARK; + } + return THEME_SYSTEM; + } + + private static class BackupData { + final ArrayList categories = new ArrayList<>(); + final ArrayList videos = new ArrayList<>(); + int themeMode; + int accentIndex; + int sortMode; + boolean uncategorizedOnly; + final ArrayList searchHistory = new ArrayList<>(); + } + + private static class Category { + String id; + String name; + + Category(String id, String name) { + this.id = id; + this.name = name; + } + + JSONObject toJson() { + JSONObject object = new JSONObject(); + try { + object.put("id", id); + object.put("name", name); + } catch (JSONException ignored) { + } + return object; + } + + static Category fromJson(JSONObject object) { + String id = object.optString("id", ""); + String name = object.optString("name", ""); + if (TextUtils.isEmpty(id) || TextUtils.isEmpty(name)) { + return null; + } + return new Category(id, name); + } + } + + private static class FavoriteVideo { + String id; + String videoId; + String url; + String thumbnailUrl; + String title; + String description; + String author; + String alias; + String memo; + ArrayList categoryIds = new ArrayList<>(); + boolean pinned; + long addedAt; + long lastOpenedAt; + + JSONObject toJson() { + JSONObject object = new JSONObject(); + try { + object.put("id", id); + object.put("videoId", videoId); + object.put("url", url); + object.put("thumbnailUrl", thumbnailUrl); + object.put("title", title); + object.put("description", description); + object.put("author", author); + object.put("alias", alias); + object.put("memo", memo); + JSONArray categoryArray = new JSONArray(); + for (String categoryId : categoryIds) { + categoryArray.put(categoryId); + } + object.put("categoryIds", categoryArray); + object.put("pinned", pinned); + object.put("addedAt", addedAt); + object.put("lastOpenedAt", lastOpenedAt); + } catch (JSONException ignored) { + } + return object; + } + + static FavoriteVideo fromJson(JSONObject object) { + FavoriteVideo video = new FavoriteVideo(); + video.id = object.optString("id", UUID.randomUUID().toString()); + video.videoId = object.optString("videoId", ""); + video.url = object.optString("url", ""); + video.thumbnailUrl = object.optString("thumbnailUrl", ""); + video.title = object.optString("title", ""); + video.description = object.optString("description", ""); + video.author = object.optString("author", ""); + video.alias = object.optString("alias", ""); + video.memo = object.optString("memo", ""); + JSONArray categoryArray = object.optJSONArray("categoryIds"); + if (categoryArray != null) { + for (int i = 0; i < categoryArray.length(); i++) { + String categoryId = categoryArray.optString(i, ""); + if (!TextUtils.isEmpty(categoryId)) { + video.categoryIds.add(categoryId); + } + } + } + video.pinned = object.optBoolean("pinned", false); + video.addedAt = object.optLong("addedAt", System.currentTimeMillis()); + video.lastOpenedAt = object.optLong("lastOpenedAt", 0L); + if (TextUtils.isEmpty(video.videoId)) { + return null; + } + if (TextUtils.isEmpty(video.url)) { + video.url = "https://www.youtube.com/watch?v=" + video.videoId; + } + if (TextUtils.isEmpty(video.thumbnailUrl)) { + video.thumbnailUrl = "https://img.youtube.com/vi/" + video.videoId + "/hqdefault.jpg"; + } + return video; + } + } + + private static class VideoMetadata { + String title = ""; + String description = ""; + String author = ""; + } + + private static class ThemePalette { + final boolean dark; + final int accent; + final int background; + final int surface; + final int textPrimary; + final int textSecondary; + final int border; + final int input; + final int chipBackground; + final int thumbnailPlaceholder; + + ThemePalette( + boolean dark, + int accent, + int background, + int surface, + int textPrimary, + int textSecondary, + int border, + int input, + int chipBackground, + int thumbnailPlaceholder + ) { + this.dark = dark; + this.accent = accent; + this.background = background; + this.surface = surface; + this.textPrimary = textPrimary; + this.textSecondary = textSecondary; + this.border = border; + this.input = input; + this.chipBackground = chipBackground; + this.thumbnailPlaceholder = thumbnailPlaceholder; + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..79aeb93 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..fc9fa18 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8d6e95f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + YouTube Favorite Search + 저장 + YouTube 링크 저장 + YouTube 링크 저장을 사용할 수 없습니다. + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..96e83e8 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..2b356a8 --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..4167a06 --- /dev/null +++ b/build.gradle @@ -0,0 +1,3 @@ +plugins { + id "com.android.application" version "9.1.0" apply false +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..d803e70 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "YoutubeFavoriteSearch" +include ":app"