컴포넌트 · 화면 순서 · 좌우로 밀어 화면 이동 · 알림 아이콘

- app/components.dart: 기능 묶음 (MKV · 웹 브라우저 · 파일 탐색기 · Rsync · 다운로드 · 이미지/PDF/ZIP 보기 · 문서 미리보기).
  설정 components (설치한 것) · navOrder (화면 순서) · swipeNav.
- 위쪽 이동 버튼: 설치한 화면만 순서대로 (하나뿐이면 그것만). 홈 화면을 제거했으면 설치된 첫 화면.
- SwipeNav: 화면 가운데를 왼쪽으로 밀면 다음, 오른쪽으로 밀면 이전 화면 (끝 다음은 처음).
- 환경 설정 > 컴포넌트: 켜기 · 끄기, 화면 순서 (끌어서), 좌우로 밀기 켜기 · 끄기.
- Android 작업 알림: 받는 중이면 내려받기 아이콘, 동기화 · 작업 중이면 좌우로 오가는 화살표 (animation-list).
- 시험: components_test (설치 · 순서 · 저장, 버튼 줄, 좌우로 밀기).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 14:37:24 +09:00
parent 2553107aa5
commit 6eb0dfc70a
25 changed files with 520 additions and 95 deletions

View File

@ -23,7 +23,8 @@ class KeepAliveService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val text = intent?.getStringExtra(EXTRA_TEXT) ?: ""
val progress = intent?.getIntExtra(EXTRA_PROGRESS, -1) ?: -1
val n = notification(this, text, progress)
val icon = intent?.getStringExtra(EXTRA_ICON) ?: ""
val n = notification(this, text, progress, icon)
running = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// 실시간 동기화는 앱이 켜져 있는 내내 지켜봐야 하므로 하루 6시간 제한이 있는 dataSync 대신 specialUse (Android 14+)
@ -58,17 +59,18 @@ class KeepAliveService : Service() {
const val NOTIFICATION_ID = 1
const val EXTRA_TEXT = "text"
const val EXTRA_PROGRESS = "progress"
const val EXTRA_ICON = "icon"
/// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음)
@Volatile
var running = false
fun update(context: Context, text: String, progress: Int) {
fun update(context: Context, text: String, progress: Int, icon: String) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, notification(context, text, progress))
nm.notify(NOTIFICATION_ID, notification(context, text, progress, icon))
}
private fun notification(context: Context, text: String, progress: Int): Notification {
private fun notification(context: Context, text: String, progress: Int, icon: String): Notification {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(CHANNEL_ID) == null) {
nm.createNotificationChannel(
@ -91,7 +93,8 @@ class KeepAliveService : Service() {
} else {
@Suppress("DEPRECATION") Notification.Builder(context)
}
b.setSmallIcon(android.R.drawable.stat_sys_download)
// 다운로드 중이면 내려받기 아이콘, 그 밖 (실시간 동기화 · MKV · AI 자막) 은 좌우로 오가는 화살표
b.setSmallIcon(if (icon == "download") android.R.drawable.stat_sys_download else R.drawable.ic_stat_sync_anim)
.setContentTitle("JJ_MKVMaker 작업 중")
.setContentText(text)
.setContentIntent(open)

View File

@ -142,7 +142,8 @@ class MainActivity : FlutterActivity() {
)
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1)
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1,
call.argument<String>("icon") ?: "")
result.success(null)
}
"stopKeepAlive" -> {
@ -409,7 +410,7 @@ class MainActivity : FlutterActivity() {
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
private var askedNotifications = false
private fun keepAlive(text: String, progress: Int) {
private fun keepAlive(text: String, progress: Int, icon: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
@ -417,12 +418,13 @@ class MainActivity : FlutterActivity() {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2)
}
if (KeepAliveService.running) {
KeepAliveService.update(applicationContext, text, progress)
KeepAliveService.update(applicationContext, text, progress, icon)
return
}
val i = Intent(applicationContext, KeepAliveService::class.java)
.putExtra(KeepAliveService.EXTRA_TEXT, text)
.putExtra(KeepAliveService.EXTRA_PROGRESS, progress)
.putExtra(KeepAliveService.EXTRA_ICON, icon)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i)
else applicationContext.startService(i)

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M2,7 L14,7 L14,4 L19,8 L14,12 L14,9 L2,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M22,15 L10,15 L10,12 L5,16 L10,20 L10,17 L22,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3.5,7 L15.5,7 L15.5,4 L20.5,8 L15.5,12 L15.5,9 L3.5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M20.5,15 L8.5,15 L8.5,12 L3.5,16 L8.5,20 L8.5,17 L20.5,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M5,7 L17,7 L17,4 L22,8 L17,12 L17,9 L5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M19,15 L7,15 L7,12 L2,16 L7,20 L7,17 L19,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M6.5,7 L18.5,7 L18.5,4 L23.5,8 L18.5,12 L18.5,9 L6.5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M17.5,15 L5.5,15 L5.5,12 L0.5,16 L5.5,20 L5.5,17 L17.5,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M5,7 L17,7 L17,4 L22,8 L17,12 L17,9 L5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M19,15 L7,15 L7,12 L2,16 L7,20 L7,17 L19,17 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3.5,7 L15.5,7 L15.5,4 L20.5,8 L15.5,12 L15.5,9 L3.5,9 Z"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M20.5,15 L8.5,15 L8.5,12 L3.5,16 L8.5,20 L8.5,17 L20.5,17 Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 실시간 동기화 · 작업 중 알림 아이콘: 위 화살표는 오른쪽, 아래 화살표는 왼쪽으로 오간다 -->
<animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false">
<item android:drawable="@drawable/ic_stat_sync_0" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_1" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_2" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_3" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_4" android:duration="150"/>
<item android:drawable="@drawable/ic_stat_sync_5" android:duration="150"/>
</animation-list>

View File

@ -74,6 +74,7 @@
"Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "The Chrome engine can be used after restarting the program. Restart now?",
"Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Cannot download the Chrome engine (HTTP {0})",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}",
"DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "View DOC · DOCX · HWP · XLS · XLSX converted to PDF (downloads a converter)",
"Edge (내장)": "Edge (built-in)",
"Edge 에서 가져오기": "Import from Edge",
"Edge 에서 가져옴": "Imported from Edge",
@ -139,6 +140,7 @@
"Windows 기본 브라우저": "Windows default browser",
"Windows 기본 프로그램": "Windows default program",
"Windows 탐색기": "Windows Explorer",
"YouTube · 토렌트 다운로드 목록": "YouTube · torrent download list",
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp, magnet · .torrent → aria2 (no seeding after completion)",
"YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube is asking for a robot check. In Settings > Downloads > YouTube cookies, choose a browser (Firefox recommended) or a cookies.txt, then download again.",
"YouTube 광고 배너 숨기기": "Hide YouTube ad banners",
@ -278,6 +280,7 @@
"권한 허용 화면 열기": "Open the permission screen",
"그 파일 하나만": "Only that file",
"그리스어": "Greek",
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading",
"글을 격자에 반영": "Apply text to grid",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)",
"금": "Fri",
@ -422,6 +425,7 @@
"동시 다운로드 수": "Simultaneous downloads",
"동시에 처리할 개수 (0 = 무한)": "Number processed at once (0 = unlimited)",
"동영상": "Video",
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "Merge video + subtitles into MKV · AI subtitles · picture adjustments",
"동영상 {0}개 · 전체 선택": "{0} videos · select all",
"동영상 목록 · 창 위치 저장": "Save video list · window position",
"동영상 목록에 추가: {0}": "Added to the video list: {0}",
@ -442,6 +446,7 @@
"두 창": "Two panes",
"두 창 배치": "Two-pane arrangement",
"두 창 사이": "Between panes",
"두 창 파일 탐색기 · 복사 · 이동 · WebDAV": "Dual-pane file explorer · copy · move · WebDAV",
"뒤로": "Back",
"등록": "Register",
"등록 중 일부가 실패했습니다.": "Some registrations failed.",
@ -491,6 +496,7 @@
"목록의 동영상 {0}개 모두": "All {0} videos in the list",
"몽골어": "Mongolian",
"무한": "Unlimited",
"문서 미리보기": "Document preview",
"문자셋 경고": "Charset warning",
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
"미얀마어": "Burmese",
@ -627,6 +633,7 @@
"설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.",
"설치됨": "Installed",
"설치하는 중": "Installing",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)",
"설치하지 못했습니다.\n{0}": "Could not install.\n{0}",
"성공: {0}": "Succeeded: {0}",
"세로 4:5": "Portrait 4:5",
@ -703,6 +710,7 @@
"앱 아이콘": "App icon",
"앱 안 브라우저 (권장)": "In-app browser (recommended)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "In-app web browser · bookmarks · video downloads",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
"앱 지우기": "Uninstall app",
"앱에 들어 있는 것 (기본)": "Built into the app (default)",
@ -806,6 +814,7 @@
"위 폴더": "Parent folder",
"위로": "Up",
"위아래": "Top and bottom",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "Order of the top navigation buttons and of swiping. Drag a row to change it.",
"유형": "Type",
"음성": "Audio",
"음성 {0}개": "{0} audio",
@ -872,6 +881,7 @@
"이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.",
"이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}",
"이미 있는 폴더입니다: {0}": "The folder already exists: {0}",
"이미지 · PDF · ZIP 보기": "Image · PDF · ZIP viewer",
"이미지 자막은 번역할 수 없습니다": "Image subtitles cannot be translated",
"이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited",
"이전": "Previous",
@ -1002,6 +1012,8 @@
"좌 → 우": "Left → right",
"좌 ⇄ 우": "Left ⇄ right",
"좌우": "Side by side",
"좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "Sync two folders (rsync) · monitoring · live sync (lsync)",
"좌우로 밀어 화면 이동": "Swipe left/right to switch screens",
"주말 종일": "Weekends, all day",
"주소": "Address",
"주소 입력 또는 검색 (Ctrl+L)": "Enter an address or search (Ctrl+L)",
@ -1094,6 +1106,7 @@
"취소했습니다.": "Cancelled.",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).",
"칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.",
"컴포넌트": "Components",
"켜져 있는 창에서 (기본)": "In the open window (default)",
"코덱": "Codec",
"크기": "Size",
@ -1206,15 +1219,18 @@
"화": "Tue",
"화면": "Display",
"화면 · 색 보정": "Frame · color",
"화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).": "Swipe left in the middle of the screen for the next screen, right for the previous one (after the last comes the first).",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.",
"화면 모양 따라": "Follow screen shape",
"화면 방향": "Screen orientation",
"화면 비율": "Aspect ratio",
"화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "Aspect ratio (landscape · portrait) · rotation · brightness · contrast · saturation · temperature",
"화면 순서": "Screen order",
"화면 언어": "Display language",
"화면 작게": "Smaller",
"화면 크게": "Larger",
"화면 크기": "Size",
"화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.": "At least one component with a screen must stay installed.",
"화질": "Quality",
"확인": "OK",
"확인 · 압축 푸는 중…": "Verifying · extracting…",

View File

@ -74,6 +74,7 @@
"Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "Chrome エンジンはプログラムを再起動すると使えます。今すぐ再起動しますか?",
"Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Chrome エンジンをダウンロードできません(HTTP {0})",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}",
"DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "DOC・DOCX・HWP・XLS・XLSX を PDF に変換して表示 (変換ツールをダウンロード)",
"Edge (내장)": "Edge(内蔵)",
"Edge 에서 가져오기": "Edge から取り込む",
"Edge 에서 가져옴": "Edge から取り込み",
@ -139,6 +140,7 @@
"Windows 기본 브라우저": "Windows の既定のブラウザー",
"Windows 기본 프로그램": "Windows の既定のプログラム",
"Windows 탐색기": "Windows エクスプローラー",
"YouTube · 토렌트 다운로드 목록": "YouTube・トレント ダウンロード一覧",
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp、マグネット・.torrent → aria2(完了後のアップロードなし)",
"YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube がロボット確認を求めています。環境設定 > ダウンロード > YouTube クッキー でブラウザー(Firefox 推奨)または cookies.txt を指定してから、もう一度ダウンロードしてください。",
"YouTube 광고 배너 숨기기": "YouTube の広告バナーを隠す",
@ -278,6 +280,7 @@
"권한 허용 화면 열기": "権限の許可画面を開く",
"그 파일 하나만": "そのファイルだけ",
"그리스어": "ギリシャ語",
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示",
"글을 격자에 반영": "テキストをグリッドに反映",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)",
"금": "金",
@ -422,6 +425,7 @@
"동시 다운로드 수": "同時ダウンロード数",
"동시에 처리할 개수 (0 = 무한)": "同時に処理する数(0 = 無制限)",
"동영상": "動画",
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "動画 + 字幕を MKV に結合・AI 字幕・画面補正",
"동영상 {0}개 · 전체 선택": "動画 {0}個・すべて選択",
"동영상 목록 · 창 위치 저장": "動画リスト・ウィンドウ位置を保存",
"동영상 목록에 추가: {0}": "動画リストに追加: {0}",
@ -442,6 +446,7 @@
"두 창": "2 ペイン",
"두 창 배치": "2 ペインの配置",
"두 창 사이": "ペインの間",
"두 창 파일 탐색기 · 복사 · 이동 · WebDAV": "2 画面ファイルエクスプローラー・コピー・移動・WebDAV",
"뒤로": "戻る",
"등록": "登録",
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
@ -491,6 +496,7 @@
"목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて",
"몽골어": "モンゴル語",
"무한": "無制限",
"문서 미리보기": "文書プレビュー",
"문자셋 경고": "文字コードの警告",
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
"미얀마어": "ミャンマー語",
@ -627,6 +633,7 @@
"설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。",
"설치됨": "インストール済み",
"설치하는 중": "インストール中",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)",
"설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}",
"성공: {0}": "成功: {0}",
"세로 4:5": "縦 4:5",
@ -703,6 +710,7 @@
"앱 아이콘": "アプリアイコン",
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "アプリ内 Web ブラウザー・ブックマーク・動画ダウンロード",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 지우기": "アプリを削除",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
@ -806,6 +814,7 @@
"위 폴더": "上のフォルダー",
"위로": "上へ",
"위아래": "上下",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "上部の移動ボタンと左右スワイプの順番です。行をドラッグして変更します。",
"유형": "種類",
"음성": "音声",
"음성 {0}개": "音声 {0}個",
@ -872,6 +881,7 @@
"이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。",
"이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}",
"이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}",
"이미지 · PDF · ZIP 보기": "画像・PDF・ZIP ビューアー",
"이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません",
"이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません",
"이전": "前へ",
@ -1002,6 +1012,8 @@
"좌 → 우": "左 → 右",
"좌 ⇄ 우": "左 ⇄ 右",
"좌우": "左右",
"좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "左右 2 つのフォルダーを同期 (rsync)・モニタリング・リアルタイム同期 (lsync)",
"좌우로 밀어 화면 이동": "左右スワイプで画面を移動",
"주말 종일": "週末 終日",
"주소": "アドレス",
"주소 입력 또는 검색 (Ctrl+L)": "アドレスを入力または検索(Ctrl+L)",
@ -1094,6 +1106,7 @@
"취소했습니다.": "キャンセルしました。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。",
"칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。",
"컴포넌트": "コンポーネント",
"켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)",
"코덱": "コーデック",
"크기": "サイズ",
@ -1206,15 +1219,18 @@
"화": "火",
"화면": "画面",
"화면 · 색 보정": "画面・カラー補正",
"화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).": "画面中央を左へスワイプで次の画面、右へで前の画面 (最後の次は最初へ)。",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。",
"화면 모양 따라": "画面の形に合わせる",
"화면 방향": "画面の向き",
"화면 비율": "画面比率",
"화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比率(横・縦)・回転・明るさ・コントラスト・彩度・色温度",
"화면 순서": "画面の順番",
"화면 언어": "表示言語",
"화면 작게": "画面を小さく",
"화면 크게": "画面を大きく",
"화면 크기": "画面サイズ",
"화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.": "画面のあるコンポーネントを少なくとも1つ残す必要があります。",
"화질": "画質",
"확인": "OK",
"확인 · 압축 푸는 중…": "確認・展開中…",

View File

@ -74,6 +74,7 @@
"Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "重新启动程序后即可使用 Chrome 引擎。现在重新启动吗?",
"Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "无法下载 Chrome 引擎(HTTP {0})",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}",
"DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "将 DOC · DOCX · HWP · XLS · XLSX 转为 PDF 查看 (需下载转换器)",
"Edge (내장)": "Edge(内置)",
"Edge 에서 가져오기": "从 Edge 导入",
"Edge 에서 가져옴": "从 Edge 导入",
@ -139,6 +140,7 @@
"Windows 기본 브라우저": "Windows 默认浏览器",
"Windows 기본 프로그램": "Windows 默认程序",
"Windows 탐색기": "Windows 资源管理器",
"YouTube · 토렌트 다운로드 목록": "YouTube · 种子下载列表",
"YouTube → yt-dlp, 마그넷 · .torrent → aria2 (완료 후 올려 주기 안 함)": "YouTube → yt-dlp,磁力链接 · .torrent → aria2(完成后不做种)",
"YouTube 가 로봇 확인을 요구합니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 브라우저(Firefox 권장) 또는 cookies.txt 를 지정한 뒤 다시 받으세요.": "YouTube 要求进行机器人验证。请在 设置 > 下载 > YouTube Cookie 中指定浏览器(推荐 Firefox)或 cookies.txt 后重新下载。",
"YouTube 광고 배너 숨기기": "隐藏 YouTube 广告横幅",
@ -278,6 +280,7 @@
"권한 허용 화면 열기": "打开权限授予界面",
"그 파일 하나만": "仅该文件",
"그리스어": "希腊语",
"그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读",
"글을 격자에 반영": "将文本应用到网格",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)",
"금": "五",
@ -422,6 +425,7 @@
"동시 다운로드 수": "同时下载数",
"동시에 처리할 개수 (0 = 무한)": "同时处理的数量(0 = 无限制)",
"동영상": "视频",
"동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정": "将视频 + 字幕合并为 MKV · AI 字幕 · 画面调整",
"동영상 {0}개 · 전체 선택": "视频 {0} 个 · 全选",
"동영상 목록 · 창 위치 저장": "保存视频列表 · 窗口位置",
"동영상 목록에 추가: {0}": "已加入视频列表: {0}",
@ -442,6 +446,7 @@
"두 창": "双窗格",
"두 창 배치": "双窗格排列",
"두 창 사이": "窗格之间",
"두 창 파일 탐색기 · 복사 · 이동 · WebDAV": "双窗格文件浏览器 · 复制 · 移动 · WebDAV",
"뒤로": "后退",
"등록": "注册",
"등록 중 일부가 실패했습니다.": "部分注册失败。",
@ -491,6 +496,7 @@
"목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频",
"몽골어": "蒙古语",
"무한": "无限制",
"문서 미리보기": "文档预览",
"문자셋 경고": "字符集警告",
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
"미얀마어": "缅甸语",
@ -627,6 +633,7 @@
"설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。",
"설치됨": "已安装",
"설치하는 중": "正在安装",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)",
"설치하지 못했습니다.\n{0}": "未能安装。\n{0}",
"성공: {0}": "成功: {0}",
"세로 4:5": "竖屏 4:5",
@ -703,6 +710,7 @@
"앱 아이콘": "应用图标",
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
"앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드": "应用内浏览器 · 书签 · 视频下载",
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 지우기": "卸载应用",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)",
@ -806,6 +814,7 @@
"위 폴더": "上级文件夹",
"위로": "向上",
"위아래": "上下",
"위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.": "顶部导航按钮和左右滑动的顺序。拖动行即可更改。",
"유형": "类型",
"음성": "音频",
"음성 {0}개": "音频 {0} 个",
@ -872,6 +881,7 @@
"이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。",
"이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}",
"이미 있는 폴더입니다: {0}": "文件夹已存在: {0}",
"이미지 · PDF · ZIP 보기": "图片 · PDF · ZIP 查看",
"이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译",
"이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑",
"이전": "上一个",
@ -1002,6 +1012,8 @@
"좌 → 우": "左 → 右",
"좌 ⇄ 우": "左 ⇄ 右",
"좌우": "左右",
"좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)": "同步左右两个文件夹 (rsync) · 监控 · 实时同步 (lsync)",
"좌우로 밀어 화면 이동": "左右滑动切换界面",
"주말 종일": "周末 全天",
"주소": "地址",
"주소 입력 또는 검색 (Ctrl+L)": "输入地址或搜索(Ctrl+L)",
@ -1094,6 +1106,7 @@
"취소했습니다.": "已取消。",
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。",
"칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。",
"컴포넌트": "组件",
"켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)",
"코덱": "编解码器",
"크기": "大小",
@ -1206,15 +1219,18 @@
"화": "二",
"화면": "显示",
"화면 · 색 보정": "画面 · 色彩",
"화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).": "在屏幕中间向左滑动进入下一个界面,向右滑动返回上一个界面 (最后一个之后回到第一个)。",
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。",
"화면 모양 따라": "跟随屏幕形状",
"화면 방향": "屏幕方向",
"화면 비율": "画面比例",
"화면 비율 (가로 · 세로) · 회전 · 밝기 · 대비 · 채도 · 색온도": "画面比例(横屏 · 竖屏)· 旋转 · 亮度 · 对比度 · 饱和度 · 色温",
"화면 순서": "界面顺序",
"화면 언어": "界面语言",
"화면 작게": "缩小界面",
"화면 크게": "放大界面",
"화면 크기": "画面大小",
"화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.": "至少要保留一个带界面的组件。",
"화질": "画质",
"확인": "确定",
"확인 · 압축 푸는 중…": "正在校验 · 解压…",

53
lib/app/components.dart Normal file
View File

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
/// 앱의 기능 묶음 (컴포넌트). 코드는 앱에 들어 있고, 환경 설정 > 컴포넌트에서 설치 (필요한 파일을 받아 켬) · 제거 (끔) 한다.
/// 화면이 있는 것 ([page]) 은 위쪽 이동 버튼 · 좌우로 밀기로 오간다 (순서는 [AppSettings.navOrder]).
class AppComponent {
final String id;
/// 화면 이름 (tr() 로 번역)
final String name;
final String description;
final IconData icon;
/// 위쪽 이동 버튼 · 좌우로 밀기로 오가는 화면
final bool page;
/// 설치할 때 받아야 하는 파일이 있음 (GitHub 릴리스의 components.json)
final bool needsDownload;
/// 처음 설치할 때부터 켜져 있음
final bool builtIn;
const AppComponent(this.id, this.name, this.description, this.icon,
{this.page = true, this.needsDownload = false, this.builtIn = true});
static const mkv = AppComponent('mkv', 'MKV 만들기', '동영상 + 자막을 MKV 로 합치기 · AI 자막 · 화면 보정', Icons.video_library_outlined);
static const browser = AppComponent('browser', '웹 브라우저', '앱 안 웹 브라우저 · 즐겨찾기 · 동영상 다운로드', Icons.public);
static const explorer = AppComponent('explorer', '파일 탐색기', '두 창 파일 탐색기 · 복사 · 이동 · WebDAV', Icons.folder_copy_outlined);
static const rsync = AppComponent('rsync', 'Rsync', '좌우 두 폴더 맞추기 (rsync) · 모니터링 · 실시간 동기화 (lsync)', Icons.sync_alt);
static const downloads = AppComponent('downloads', '다운로드', 'YouTube · 토렌트 다운로드 목록', Icons.download_for_offline_outlined);
static const viewer = AppComponent('viewer', '이미지 · PDF · ZIP 보기', '그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기',
Icons.photo_library_outlined, page: false);
static const docs = AppComponent('docs', '문서 미리보기', 'DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)',
Icons.description_outlined, page: false, needsDownload: true, builtIn: false);
static const all = [mkv, browser, explorer, rsync, downloads, viewer, docs];
/// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼)
static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads'];
static AppComponent? byId(String id) => all.where((c) => c.id == id).firstOrNull;
/// 처음 설치 때 켜진 것
static List<String> get defaultInstalled => [for (final c in all) if (c.builtIn) c.id];
/// 설치된 화면들을 [order] 순서로 (순서에 없는 것은 기본 순서대로 뒤에)
static List<AppComponent> pages(List<String> installed, List<String> order) {
final ids = [
...order.where(defaultOrder.contains),
...defaultOrder.where((x) => !order.contains(x)),
];
return [for (final id in ids) if (installed.contains(id)) byId(id)!];
}
}

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'components.dart';
import '../core/download_detect.dart';
import '../core/encode_options.dart';
import '../core/playlist.dart';
@ -332,6 +333,15 @@ class AppSettings {
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
List<String> rsyncPaths = [];
/// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두
List<String> components = AppComponent.defaultInstalled;
/// 화면 순서 (위쪽 이동 버튼 · 화면 가운데를 좌우로 밀어 이동): 마지막 다음은 처음으로
List<String> navOrder = AppComponent.defaultOrder;
/// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로
bool swipeNav = true;
/// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만.
List<DavServer> webdavServers = [];
@ -444,6 +454,9 @@ class AppSettings {
'copyMethodFile': copyMethodFile,
'copyMethodFolder': copyMethodFolder,
'rsyncPaths': rsyncPaths,
'components': components,
'navOrder': navOrder,
'swipeNav': swipeNav,
'webdavServers': [for (final x in webdavServers) x.toJson()],
'rsyncOptions': rsyncOptions,
'robocopyOptions': robocopyOptions,
@ -550,6 +563,13 @@ class AppSettings {
..copyMethodFile = _method(j['copyMethodFile'])
..copyMethodFolder = _method(j['copyMethodFolder'])
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']
..components = j['components'] is List
? [for (final x in j['components'] as List) if (AppComponent.byId('$x') != null) '$x']
: AppComponent.defaultInstalled
..navOrder = j['navOrder'] is List
? [for (final x in j['navOrder'] as List) if (AppComponent.defaultOrder.contains('$x')) '$x']
: AppComponent.defaultOrder
..swipeNav = j['swipeNav'] as bool? ?? true
..webdavServers = [
for (final x in (j['webdavServers'] as List?) ?? const [])
if (x is Map) DavServer.fromJson(x),

View File

@ -34,7 +34,6 @@ import 'ui/app_actions.dart';
import 'ui/app_drop.dart';
import 'ui/browser_page.dart';
import 'ui/downloads_page.dart';
import 'ui/explorer_page.dart';
import 'ui/exit_dialog.dart';
import 'ui/home_page.dart';
import 'ui/monitor_page.dart' show askLiveSyncStart;
@ -295,13 +294,11 @@ Future<void> main(List<String> args) async {
content: Text(trf('지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 ' '{0}:{1}). ' '작업 기록에 남겨 두었습니다.', [crashed.$2.hour.toString().padLeft(2, '0'), crashed.$2.minute.toString().padLeft(2, '0')])),
));
}
// 시작 화면을 "웹 브라우저" · "파일 탐색기" 로 정했으면 그 화면을 연다 (MKV 화면은 그 아래에 있음)
if (controller.settings.startScreen == 'browser') {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(BrowserPage.open(nav, c: controller, downloads: downloads, bookmarks: bookmarks));
} else if (controller.settings.startScreen == 'files') {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(ExplorerPage.open(nav, c: controller));
// 홈 화면이 MKV 화면이 아니면 (또는 MKV 만들기를 제거했으면) 그 화면을 연다 (MKV 화면은 그 아래에 있음)
final home = AppNavButtons.homeId(controller.settings);
final nav0 = navigatorKey.currentState;
if (home != 'mkv' && nav0 != null) {
unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks));
}
if (controller.settings.liveSyncOnStart == 'ask' && ctx.mounted) await askLiveSyncStart(ctx, live);
// 다른 버전을 쓰다가 이 버전으로 돌아왔으면 보관해 둔 이 버전의 설정을 되살릴지
@ -481,12 +478,10 @@ Future<void> runAndroid(String dataDir) async {
return;
}
} catch (_) {}
if (controller.settings.startScreen == 'browser') {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(BrowserPage.open(nav, c: controller, downloads: downloads, bookmarks: bookmarks));
} else if (controller.settings.startScreen == 'files') {
final nav = navigatorKey.currentState;
if (nav != null) unawaited(ExplorerPage.open(nav, c: controller));
final home = AppNavButtons.homeId(controller.settings);
final nav0 = navigatorKey.currentState;
if (home != 'mkv' && nav0 != null) {
unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks));
}
// 다시 켤 때 실시간 동기화를 골라서 시작
final ctx0 = navigatorKey.currentContext;

View File

@ -62,9 +62,11 @@ class AndroidKeepAlive {
_last = '';
return;
}
final key = '$text|$progress';
// 알림 아이콘: 받는 중이면 내려받기, 아니면 (동기화 · 작업) 좌우로 오가는 화살표
final icon = running > 0 ? 'download' : 'sync';
final key = '$text|$progress|$icon';
if (_on && key == _last) return;
await _ch.invokeMethod<void>('keepAlive', {'text': text, 'progress': progress});
await _ch.invokeMethod<void>('keepAlive', {'text': text, 'progress': progress, 'icon': icon});
_on = true;
_last = key;
} catch (_) {}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/bookmarks_controller.dart';
import '../app/components.dart';
import '../app/download_manager.dart';
import '../app/settings.dart';
import '../services/app_shell.dart' show appIconButtonAsset;
@ -100,37 +101,108 @@ class AppNavButtons extends StatelessWidget {
this.onExplorerPage = false,
this.onRsyncPage = false});
/// 버튼 줄의 가장 넓은 폭 (JJ · 화면 5개 · 뒤로)
static const double width = 7 * 40;
/// MKV 화면 (맨 처음 화면) 까지 돌아가기
static void toMkv(BuildContext context) => Navigator.of(context).popUntil((r) => r.isFirst);
/// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 웹 브라우저 · 파일 탐색기면 그 화면을 연다
/// 홈 화면 (환경 설정의 "홈 화면") 의 컴포넌트 id. 그 컴포넌트를 제거했으면 설치된 첫 화면.
static String homeId(AppSettings s) {
final want = switch (s.startScreen) { 'browser' => 'browser', 'files' => 'explorer', _ => 'mkv' };
final pages = AppComponent.pages(s.components, s.navOrder);
if (pages.any((p) => p.id == want)) return want;
return pages.isEmpty ? 'mkv' : pages.first.id;
}
/// 그 화면으로 (이미 열려 있으면 그 화면으로 돌아감). 'mkv' 는 맨 처음 화면.
static Future<void> openPage(NavigatorState nav, String id,
{required AppController c, DownloadManager? downloads, BookmarksController? bookmarks}) async {
switch (id) {
case 'mkv':
nav.popUntil((r) => r.isFirst);
case 'browser':
if (bookmarks != null) await BrowserPage.open(nav, c: c, downloads: downloads, bookmarks: bookmarks);
case 'explorer':
await ExplorerPage.open(nav, c: c);
case 'rsync':
await ExplorerPage.openRsync(nav, c: c);
case 'downloads':
if (downloads != null) await DownloadsPage.open(nav, downloads);
}
}
/// 이 창에서 열 수 있는 화면 (브라우저 · 다운로드가 없는 새 창에서는 그 둘을 뺀다)
static List<AppComponent> pagesOf(AppScope scope) => [
for (final p in AppComponent.pages(scope.controller.settings.components, scope.controller.settings.navOrder))
if ((p.id != 'browser' || scope.bookmarks != null) && (p.id != 'downloads' || scope.downloads != null)) p,
];
static void open(BuildContext context, String id) {
final scope = AppScope.maybeOf(context);
if (scope == null) return;
openPage(Navigator.of(context), id, c: scope.controller, downloads: scope.downloads, bookmarks: scope.bookmarks);
}
/// 홈 화면으로: MKV 화면까지 돌아간 뒤, 홈 화면이 다른 화면이면 그 화면을 연다
static void toHome(BuildContext context) {
final scope = AppScope.maybeOf(context);
final nav = Navigator.of(context);
nav.popUntil((r) => r.isFirst);
if (scope == null) return;
final start = scope.controller.settings.startScreen;
if (start == 'browser' && scope.bookmarks != null) {
BrowserPage.open(nav, c: scope.controller, downloads: scope.downloads, bookmarks: scope.bookmarks!);
} else if (start == 'files') {
ExplorerPage.open(nav, c: scope.controller);
final id = homeId(scope.controller.settings);
if (id != 'mkv') open(context, id);
}
/// 지금 화면에서 [step] 만큼 (−1 이전 · +1 다음) 옮긴 화면으로. 끝 다음은 처음으로.
static void step(BuildContext context, String current, int step) {
final scope = AppScope.maybeOf(context);
if (scope == null) return;
final pages = pagesOf(scope);
if (pages.length < 2) return;
var i = pages.indexWhere((p) => p.id == current);
if (i < 0) i = 0;
open(context, pages[(i + step) % pages.length].id);
}
@override
Widget build(BuildContext context) {
final scope = AppScope.maybeOf(context);
// 컴포넌트 설치 · 순서를 바꾸면 바로 다시 그린다 (이 위젯은 const 로 쓰여 부모가 다시 그려도 그대로이므로)
if (scope == null) return _build(context, null);
return ListenableBuilder(listenable: scope.controller, builder: (context, _) => _build(context, scope));
}
Widget _build(BuildContext context, AppScope? scope) {
final nav = Navigator.maybeOf(context);
final atRoot = !(nav?.canPop() ?? false);
final start = scope?.controller.settings.startScreen;
final homeName = start == 'browser' && scope?.bookmarks != null
? tr('웹 브라우저')
: start == 'files'
? tr('파일 탐색기')
: tr('MKV 화면');
final downloads = scope?.downloads;
final current = onDownloadsPage
? 'downloads'
: onBrowserPage
? 'browser'
: onExplorerPage
? 'explorer'
: onRsyncPage
? 'rsync'
: atRoot
? 'mkv'
: '';
final pages = scope == null ? const <AppComponent>[] : pagesOf(scope);
final homeName = switch (scope == null ? 'mkv' : homeId(scope.controller.settings)) {
'browser' => tr('웹 브라우저'),
'explorer' => tr('파일 탐색기'),
'rsync' => 'Rsync',
'downloads' => tr('다운로드 목록'),
_ => tr('MKV 화면'),
};
String tip(String id, bool here) => switch (id) {
'mkv' => here ? tr('MKV 화면 (지금 여기)') : tr('MKV 화면으로'),
'browser' => here ? tr('웹 브라우저 (지금 여기)') : tr('웹 브라우저'),
'explorer' => here ? tr('파일 탐색기 (지금 여기)') : tr('파일 탐색기'),
'rsync' => here ? tr('Rsync (지금 여기)') : 'Rsync',
'downloads' => here ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'),
_ => id,
};
Widget btn(Widget icon, String tip, VoidCallback? f, {bool here = false}) => SizedBox(
width: 40,
height: 40,
@ -142,57 +214,60 @@ class AppNavButtons extends StatelessWidget {
onPressed: f,
),
);
return SizedBox(
width: width,
child: Row(children: [
return Row(mainAxisSize: MainAxisSize.min, children: [
btn(
Image.asset(appIconButtonAsset(scope?.controller.settings.appIcon),
width: 26, height: 26, filterQuality: FilterQuality.medium),
trf('홈 화면 ({0}) · 환경 설정에서 바꿈', [homeName]),
scope == null ? null : () => toHome(context),
),
// 설치한 화면들 (환경 설정 > 컴포넌트 의 순서). 하나뿐이면 버튼 줄은 그것만.
for (final p in pages)
btn(
Icon(Icons.video_library_outlined, color: atRoot ? JjColors.accent : null),
atRoot ? tr('MKV 화면 (지금 여기)') : tr('MKV 화면으로'),
atRoot ? null : () => toMkv(context),
here: atRoot,
),
// 웹 브라우저: MKV 화면 버튼 다음 (모든 화면 같은 자리)
btn(
Icon(Icons.public, color: onBrowserPage ? JjColors.accent : null),
onBrowserPage ? tr('웹 브라우저 (지금 여기)') : tr('웹 브라우저'),
scope?.bookmarks == null || onBrowserPage
? null
: () => BrowserPage.open(Navigator.of(context),
c: scope!.controller, downloads: scope.downloads, bookmarks: scope.bookmarks!),
here: onBrowserPage,
),
// 파일 탐색기: 웹 브라우저 다음
btn(
Icon(Icons.folder_copy_outlined, color: onExplorerPage ? JjColors.accent : null),
onExplorerPage ? tr('파일 탐색기 (지금 여기)') : tr('파일 탐색기'),
scope == null || onExplorerPage ? null : () => ExplorerPage.open(Navigator.of(context), c: scope.controller),
here: onExplorerPage,
),
// Rsync: 파일 탐색기 다음 (좌우 두 창 · 폴더 하나씩 골라 → ← ↔)
btn(
Icon(Icons.sync_alt, color: onRsyncPage ? JjColors.accent : null),
onRsyncPage ? tr('Rsync (지금 여기)') : 'Rsync',
scope == null || onRsyncPage ? null : () => ExplorerPage.openRsync(Navigator.of(context), c: scope.controller),
here: onRsyncPage,
Icon(p.icon, color: p.id == current ? JjColors.accent : null),
tip(p.id, p.id == current),
p.id == current ? null : () => open(context, p.id),
here: p.id == current,
),
btn(const Icon(Icons.arrow_back), tr('뒤로'),
// 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다
atRoot ? null : () => onBrowserPage ? Navigator.pop(context) : Navigator.maybePop(context)),
btn(
Icon(Icons.download_for_offline_outlined, color: onDownloadsPage ? JjColors.accent : null),
onDownloadsPage ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'),
downloads == null || onDownloadsPage
? null
: () => DownloadsPage.open(Navigator.of(context), downloads),
here: onDownloadsPage,
),
]),
]);
}
}
/// 화면 가운데를 좌우로 밀면 다음 · 이전 화면으로 (환경 설정 > 컴포넌트 의 순서, 끝 다음은 처음).
/// 안쪽의 옆으로 밀리는 것 (가로 목록 · 막대) 이 먼저 받고, 위 · 아래 가장자리 (1/5) 에서 시작한 것은 무시한다.
class SwipeNav extends StatefulWidget {
final String current;
final Widget child;
const SwipeNav({super.key, required this.current, required this.child});
@override
State<SwipeNav> createState() => _SwipeNavState();
}
class _SwipeNavState extends State<SwipeNav> {
bool _middle = false;
@override
Widget build(BuildContext context) {
final scope = AppScope.maybeOf(context);
if (scope == null) return widget.child;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (d) {
final h = context.size?.height ?? 0;
_middle = h > 0 && d.localPosition.dy > h * 0.2 && d.localPosition.dy < h * 0.8;
},
onHorizontalDragEnd: (d) {
final v = d.primaryVelocity ?? 0;
// 켜고 끄기는 밀 때 확인 (이 위젯은 설정이 바뀌어도 다시 그려지지 않을 수 있음)
if (!scope.controller.settings.swipeNav || !_middle || v.abs() < 500) return;
// 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면
AppNavButtons.step(context, widget.current, v < 0 ? 1 : -1);
},
child: widget.child,
);
}
}

View File

@ -432,7 +432,10 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
child: Focus(
autofocus: true,
child: Scaffold(
body: ListenableBuilder(
// 웹 페이지 위에서의 밀기는 웹 페이지가 받으므로, 위쪽 막대 · 즐겨찾기 줄에서 좌우로 밀면 화면 이동
body: SwipeNav(
current: 'browser',
child: ListenableBuilder(
listenable: Listenable.merge([bm, widget.c]),
builder: (context, _) => Column(children: [
_toolbar(),
@ -505,6 +508,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
),
),
),
),
);
}

View File

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/components.dart';
import '../l10n/tr.dart';
import 'setting_tile.dart';
import 'theme.dart';
/// 환경 설정 > 컴포넌트: 기능 묶음 설치 (켜기) · 제거 (끄기), 화면 순서 (끌어서 바꿈), 좌우로 밀어 화면 이동.
/// 화면이 하나만 남으면 위쪽 이동 버튼 줄에는 그것만 보인다.
class ComponentSettings extends StatelessWidget {
final AppController c;
const ComponentSettings({super.key, required this.c});
@override
Widget build(BuildContext context) {
final s = c.settings;
final installed = s.components;
final pages = AppComponent.pages(installed, s.navOrder);
Future<void> setInstalled(AppComponent x, bool on) async {
// 화면 있는 것이 하나도 남지 않게는 못 한다
if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) {
ScaffoldMessenger.maybeOf(context)
?.showSnackBar(SnackBar(content: Text(tr('화면이 있는 컴포넌트는 하나 이상 남아 있어야 합니다.'))));
return;
}
await c.updateSettings((v) => v.components = [
for (final id in v.components) if (id != x.id) id,
if (on) x.id,
]);
}
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SwitchListTile(
value: s.swipeNav,
onChanged: (v) => c.updateSettings((x) => x.swipeNav = v),
title: Text(tr('좌우로 밀어 화면 이동')),
subtitle: Text(tr('화면 가운데를 왼쪽으로 밀면 다음 화면, 오른쪽으로 밀면 이전 화면 (마지막 다음은 처음으로).')),
),
for (final x in AppComponent.all)
SettingTile(
leading: Icon(x.icon, color: installed.contains(x.id) ? JjColors.accent : JjColors.textDim),
title: Text(tr(x.name)),
subtitle: Text(x.needsDownload ? '${tr(x.description)}\n${tr('설치하면 필요한 파일을 내려받습니다 (준비 중)')}' : tr(x.description)),
trailing: Switch(
value: installed.contains(x.id),
onChanged: x.needsDownload && !installed.contains(x.id) ? null : (v) => setInstalled(x, v),
),
),
const Divider(),
SettingTile(
leading: const Icon(Icons.swap_vert),
title: Text(tr('화면 순서')),
subtitle: Text(tr('위쪽 이동 버튼과 좌우로 밀기의 순서입니다. 줄을 끌어 바꿉니다.')),
),
ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: (from, to) {
final ids = [for (final p in pages) p.id];
ids.insert(to, ids.removeAt(from));
// 설치하지 않은 화면은 원래 자리 순서대로 뒤에
c.updateSettings((v) => v.navOrder = [...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x))]);
},
children: [
for (final (i, p) in pages.indexed)
ReorderableDragStartListener(
key: ValueKey(p.id),
index: i,
child: ListTile(
dense: true,
leading: Text('${i + 1}', style: const TextStyle(color: JjColors.textDim)),
title: Row(children: [Icon(p.icon, size: 18), const SizedBox(width: 10), Text(tr(p.name))]),
trailing: const Icon(Icons.drag_handle),
),
),
],
),
]);
}
}

View File

@ -75,7 +75,9 @@ class _DownloadsPageState extends State<DownloadsPage> {
final hasSel = d.selected.isNotEmpty;
final allSel = d.tasks.isNotEmpty && d.selected.length == d.tasks.length;
return Scaffold(
body: Column(children: [
body: SwipeNav(
current: 'downloads',
child: Column(children: [
AppTopBar(
nav: const AppNavButtons(onDownloadsPage: true),
actions: const AppActions(),
@ -182,6 +184,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
),
),
]),
),
);
},
);

View File

@ -1544,7 +1544,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Focus(
autofocus: true,
child: Scaffold(
body: ListenableBuilder(
body: SwipeNav(
current: widget.rsync ? 'rsync' : 'explorer',
child: ListenableBuilder(
listenable: c,
builder: (context, _) {
_dropRemovedServers();
@ -1562,6 +1564,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
),
),
),
),
);
}

View File

@ -47,7 +47,8 @@ class HomePage extends StatelessWidget {
listenable: c,
builder: (context, _) => Scaffold(
// 끌어다 놓기는 앱 전체에서 받는다 (ui/app_drop.dart 의 AppDropArea - 어느 화면에서 놓아도 이 목록에 추가)
body: SizedBox(
body: SwipeNav(
current: 'mkv',
child: Column(
children: [
_TopBar(c: c, downloads: downloads, onExit: onExit, bookmarks: bookmarks),

View File

@ -28,6 +28,7 @@ import 'explorer_look.dart' show ExplorerStyle;
import 'theme.dart';
import '../l10n/tr.dart';
import 'setting_tile.dart';
import 'component_settings.dart';
/// 동시 작업 수 고르기: 1 · 5 · 10 · 무한(0) · 직접 입력
class CountSelector extends StatelessWidget {
@ -326,6 +327,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
]),
_group('components', Icons.extension_outlined, tr('컴포넌트'), [ComponentSettings(c: c)]),
_group('display', Icons.aspect_ratio, tr('화면'), [
if (!desk)
SettingTile(
@ -852,12 +854,13 @@ class _SettingsPageState extends State<SettingsPage> {
final _groupKeys = <String, GlobalKey>{};
static const _groupIds = [
'general', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', 'cleanup', 'about', //
'general', 'components', 'display', 'mkv', 'subtitle', 'play', 'browser', 'files', 'rsync', 'download', 'run', 'cleanup', 'about', //
];
String _groupTitle(String id) => switch (id) {
'general' => tr('일반'),
'display' => tr('화면'),
'components' => tr('컴포넌트'),
'mkv' => tr('MKV 만들기'),
'subtitle' => tr('자막 (AI · 인터넷)'),
'play' => tr('재생'),
@ -905,7 +908,7 @@ class _SettingsPageState extends State<SettingsPage> {
child: Row(children: [
Icon(icon, size: 20, color: JjColors.accent),
const SizedBox(width: 10),
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
Text(title, key: ValueKey('group-title:$id'), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
]),
),
...children,

89
test/components_test.dart Normal file
View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/components.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/home_page.dart';
AppController _plain() =>
AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
void main() {
test('컴포넌트: 설치한 화면만 순서대로 · 홈 화면은 제거되면 첫 화면 · 설정 저장', () {
final s = AppSettings();
expect(s.components, containsAll(['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'viewer']));
expect(s.components, isNot(contains('docs'))); // 변환기를 받아야 하는 것은 처음엔 꺼짐
expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id],
['mkv', 'browser', 'explorer', 'rsync', 'downloads']);
s
..navOrder = ['explorer', 'mkv']
..components = ['mkv', 'explorer', 'downloads'];
expect([for (final p in AppComponent.pages(s.components, s.navOrder)) p.id], ['explorer', 'mkv', 'downloads']);
s.startScreen = 'browser'; // 제거한 화면 → 설치된 첫 화면
expect(AppNavButtons.homeId(s), 'explorer');
final back = AppSettings.fromJson(s.toJson());
expect(back.components, ['mkv', 'explorer', 'downloads']);
expect(back.navOrder, ['explorer', 'mkv']);
// 예전 설정 (컴포넌트 없음) 은 기본
expect(AppSettings.fromJson({}).components, AppComponent.defaultInstalled);
});
testWidgets('위쪽 이동 버튼: 설치한 화면만 · 설정을 바꾸면 바로 · 하나뿐이면 그것만', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = _plain();
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, child: child!),
home: HomePage(c: c),
));
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
expect(find.byTooltip('파일 탐색기'), findsOneWidget);
expect(find.byTooltip('Rsync'), findsOneWidget);
await c.updateSettings((s) => s.components = ['mkv', 'explorer']);
await tester.pump();
expect(find.byTooltip('Rsync'), findsNothing);
expect(find.byTooltip('파일 탐색기'), findsOneWidget);
await c.updateSettings((s) => s.components = ['mkv']);
await tester.pump();
expect(find.byTooltip('파일 탐색기'), findsNothing);
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
});
testWidgets('화면 가운데를 좌우로 밀면 순서대로 다음 · 이전 화면 (끝 다음은 처음)', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = _plain();
c.settings
..components = ['mkv', 'explorer', 'rsync']
..navOrder = ['mkv', 'explorer', 'rsync'];
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, child: child!),
home: HomePage(c: c),
));
Future<void> swipe(double dx) async {
await tester.flingFrom(const Offset(750, 450), Offset(dx, 0), 2000);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
}
await swipe(-600); // 왼쪽으로 → 다음 (파일 탐색기)
expect(find.byTooltip('파일 탐색기 (지금 여기)'), findsOneWidget);
await swipe(-600);
expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget);
await swipe(-600); // 마지막 다음은 처음
expect(find.byTooltip('MKV 화면 (지금 여기)'), findsOneWidget);
await swipe(600); // 오른쪽으로 → 이전 (끝으로)
expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget);
// 끄면 밀어도 그대로
await c.updateSettings((s) => s.swipeNav = false);
await tester.pump();
await swipe(600);
expect(find.byTooltip('Rsync (지금 여기)'), findsOneWidget);
});
}

View File

@ -215,8 +215,9 @@ void main() {
for (final g in groups) {
await tester.tap(find.widgetWithText(ActionChip, g));
await tester.pumpAndSettle();
// 묶음 제목 (버튼이 아닌 카드 안의 글) 이 화면 위쪽에 보인다
final title = find.descendant(of: find.byType(Card), matching: find.text(g)).first;
// 묶음 제목 (버튼이 아닌 카드 머리의 글) 이 화면 위쪽에 보인다 (컴포넌트 묶음의 같은 이름 줄과 헷갈리지 않게 머리 글만)
final title = find.byWidgetPredicate((w) =>
w is Text && w.data == g && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('group-title:'));
final y = tester.getTopLeft(title).dy;
expect(y, inInclusiveRange(0, 400), reason: '$g 묶음 제목 위치 $y');
}