Compare commits

..

No commits in common. "main" and "408cf3101addff9690de3e14349bea0121ed862c" have entirely different histories.

588 changed files with 1584 additions and 61182 deletions

13
.gitignore vendored
View File

@ -56,16 +56,3 @@ app.*.map.json
/jj_aria2/ /jj_aria2/
*.log *.log
/android/local.properties /android/local.properties
# 내장 Chrome: 빌드할 때 내려받는 CEF 배포본 (약 330MB)
/packages/webview_cef/third/cef/
# Android 빌드 중간 파일
/android/build/
/packages/*/android/.cxx/
/packages/*/android/build/
# 플러그인 사본 안에 IDE 가 만든 Gradle 캐시
packages/*/android/.gradle/
# 플러그인 사본에서 pub get 을 하면 생기는 잠금 파일 (앱의 pubspec.lock 이 버전을 정함)
packages/*/pubspec.lock

View File

@ -24,17 +24,14 @@
| **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) | | **탐색기 연결** | 오른쪽 클릭 "JJ_MKVMaker 로 재생" / "JJ_MKVMaker 로 자막 만들기", 끌어다 놓기. 더블클릭(연결 프로그램)으로 열면 바로 재생 또는 목록에 추가, 켜져 있는 창 / 새 창 (환경 설정) |
| **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 | | **창 · 종료** | 창 위치 · 동영상 목록 기억, 여러 창의 목록 자동 맞춤. 종료(✕)를 누르면 백그라운드로 계속 / 모두 종료 / 매번 고르기 (환경 설정). 완전 종료는 트레이 아이콘 > 종료 |
| **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 | | **다운로드** | Ctrl+C 로 복사한 YouTube 주소 → yt-dlp, 마그넷 · .torrent → aria2 (백그라운드, 트레이). 다 받은 영상은 편집 목록에 자동 추가 |
| **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유, 다른 언어 페이지를 화면 언어로 자동 번역 (Google 번역), JavaScript 끄기 | | **웹 브라우저** | 앱 안 브라우저(Edge WebView2)로 보다가 [다운로드], 즐겨찾기(Chrome · Edge · Whale 에서 가져오기), 로그인 쿠키를 yt-dlp 와 공유 |
| **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 | | **작업 대기열 · 화면 분할** | AI 자막 · 번역 · MKV 만들기를 차례로 백그라운드 실행, 브라우저 옆 [작업 현황] 에서 진행 확인 |
| **일괄 작업** | 동영상 목록에서 여러 개를 체크(전체 선택 · 이름/날짜 정렬) → [자막 만들기] · [자막 만들기 & MKV 만들기] · [선택한 파일 재생] · [MKV 만들기] |
| **상태 표시 · 화면 크기** | 모든 화면 오른쪽 위에 CPU · MEM · 다운로드 디스크 남은 용량, 화면 크기 [−] 100% [+], 환경 설정 · 종료 버튼 |
| **끌어다 놓기 · 오른쪽 클릭** | 어느 화면에서든 동영상 · 폴더를 놓으면 목록에 추가. 목록에서 오른쪽 클릭: 폴더 열기 · 재생 · 자막 만들기 · 자막 찾기 · 삭제 |
## 실행 ## 실행
1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다. 1. [Releases](../../releases) 에서 `JJ_MKVMaker_v*_win64.zip` 을 받아 원하는 폴더에 풉니다.
2. `jj_mkvmaker.exe` 를 실행합니다. (Windows 10/11 64비트) 2. `jj_mkvmaker.exe` 를 실행합니다. (Windows 10/11 64비트)
3. 폴더 전체가 프로그램입니다. 맨 위의 `jj_mkvmaker.exe` 는 `Lib\` 안의 프로그램을 실행하는 시작 프로그램이고, 작업 기록은 `Logs\` 에 남습니다. exe 파일 하나만 옮기면 동작하지 않습니다. 3. 폴더 전체가 프로그램입니다. exe 파일 하나만 옮기면 동작하지 않습니다.
처음 쓸 때 내려받는 것 (한 번만): 처음 쓸 때 내려받는 것 (한 번만):
@ -60,12 +57,7 @@ OpenSubtitles 의 무료 API 키가 필요합니다.
| 플레이어 | Space 재생/정지 · ←/→ 10초 · Shift+←/→ 1분 · ↑/↓ 음량 · F 전체 화면 · Esc 나가기 · N/P 다음/이전 · M 음소거 · S 자막 · L 재생 목록 | | 플레이어 | Space 재생/정지 · ←/→ 10초 · Shift+←/→ 1분 · ↑/↓ 음량 · F 전체 화면 · Esc 나가기 · N/P 다음/이전 · M 음소거 · S 자막 · L 재생 목록 |
| 자막 편집 | Ctrl+Space 재생/정지 · Alt+←/→ 1초 · Alt+Shift+←/→ 0.1초 · F7 새 줄 · F8 선택 줄 재생 · F9 시작=현재 · F10 끝=현재 | | 자막 편집 | Ctrl+Space 재생/정지 · Alt+←/→ 1초 · Alt+Shift+←/→ 0.1초 · F7 새 줄 · F8 선택 줄 재생 · F9 시작=현재 · F10 끝=현재 |
| 브라우저 | Ctrl+L 주소창 · F5 새로고침 · Alt+←/→ 뒤로/앞으로 · Ctrl+D 즐겨찾기 · Ctrl+Shift+B 즐겨찾기 관리 · Ctrl+Shift+J 작업 현황 | | 브라우저 | Ctrl+L 주소창 · F5 새로고침 · Alt+←/→ 뒤로/앞으로 · Ctrl+D 즐겨찾기 · Ctrl+Shift+B 즐겨찾기 관리 · Ctrl+Shift+J 작업 현황 |
| 어디서나 | Ctrl+Shift+X 창 보이기 / 트레이로 숨기기 (환경 설정에서 변경) | | 어디서나 | Ctrl+Shift+X 창 보이기 (환경 설정에서 변경) |
## 버전
`년.월.일_순번` (예: `2026.09.30_001`). 같은 날 다시 내면 순번이 늘고, 날이 바뀌면 `001` 부터 다시 시작합니다.
번호 올리기: `powershell -File tool\next_version.ps1` (pubspec.yaml 에는 Flutter 형식 `2026.9.30+1` 로 적힙니다)
## 소스에서 빌드 ## 소스에서 빌드

View File

@ -51,22 +51,6 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지
위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다. 위 GPL 프로그램은 수정하지 않은 공식 배포 파일이며, 해당 소스는 위 주소에서 받을 수 있습니다.
[Android 판 (APK) 에만 들어 있는 구성 요소]
- FFmpegKit (ffmpeg_kit_flutter_new_min_gpl, JJ_MKVMaker 수정본)
GPL-3.0 (FFmpeg GPL 빌드 포함) https://pub.dev/packages/ffmpeg_kit_flutter_new_min_gpl
수정 내용: app\packages\ffmpeg_kit_flutter_new_min_gpl\JJ_PATCH.md
- youtubedl-android (Python · yt-dlp · FFmpeg · aria2c 실행 파일 포함)
GPL-3.0 https://github.com/JunkFood02/youtubedl-android
· Python PSF License https://www.python.org
· QuickJS MIT https://bellard.org/quickjs/
· yt-dlp Unlicense
· FFmpeg GPL https://ffmpeg.org
· aria2 GPL-2.0-or-later https://github.com/aria2/aria2
수정하지 않은 공식 배포본 (Maven Central io.github.junkfood02.youtubedl-android 0.18.1) 이며,
소스는 위 주소에서 받을 수 있습니다.
- libmpv (media_kit_libs_android_video) LGPL-2.1+ https://github.com/media-kit/libmpv-android-video-build
[처음 사용할 때 내려받는 AI 모델] [처음 사용할 때 내려받는 AI 모델]
- Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp - Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp

View File

@ -14,9 +14,6 @@ analyzer:
- build/** - build/**
- android/** - android/**
- windows/** - windows/**
- packages/webview_cef/**
- packages/flutter_inappwebview_android/**
- packages/ffmpeg_kit_flutter_new_min_gpl/** # 고친 외부 부품 (원본 코드 그대로)
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the

View File

@ -1,22 +1,13 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// 배포 서명 키 (android/key.properties, 저장소에 올리지 않음). 없으면 디버그 키로 서명.
val keyProps = Properties().apply {
val f = rootProject.file("key.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
android { android {
namespace = "com.jj.jj_mkvmaker" namespace = "com.jj.jj_mkvmaker"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
// whisper_ggml 이 NDK 29 를 요구 (더 낮은 버전용 코드와 호환) ndkVersion = flutter.ndkVersion
ndkVersion = "29.0.13113456"
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
@ -24,46 +15,25 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.jj.jj_mkvmaker" applicationId = "com.jj.jj_mkvmaker"
// ffmpeg-kit 은 Android 7.0 (API 24) 이상 // You can update the following values to match your application needs.
minSdk = maxOf(24, flutter.minSdkVersion) // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
// 버전 "년.월.일+순번" 의 순번은 날마다 1 부터 다시 시작하므로 그대로 쓰면 다음 날 판이 "더 낮은 버전" 으로 // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// 설치가 거부된다. 날짜를 앞에 붙여 늘 커지게 한다: 2026.10.2+1 → 261002001 // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
versionCode = flutter.versionName.split(".").let { (y, m, d) -> // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
(y.toInt() % 100) * 10_000_000 + m.toInt() * 100_000 + d.toInt() * 1_000 + flutter.versionCode // flag during build.
} versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
// 요즘 휴대폰 · 태블릿 (64비트 ARM) 만. 플러그인이 다른 CPU 용 라이브러리를 넣어도 앱 본체가 없으면 실행되지 않으므로 뺀다
ndk {
abiFilters += listOf("arm64-v8a")
}
}
signingConfigs {
if (keyProps.getProperty("storeFile") != null) {
create("release") {
storeFile = file(keyProps.getProperty("storeFile"))
storePassword = keyProps.getProperty("storePassword")
keyAlias = keyProps.getProperty("keyAlias")
keyPassword = keyProps.getProperty("keyPassword")
}
}
} }
buildTypes { buildTypes {
release { release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") // TODO: Add your own signing config for the release build.
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug") // Signing with the debug keys for now, so `flutter run --release` works.
} signingConfig = signingConfigs.getByName("debug")
}
// ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드)
packaging {
jniLibs {
useLegacyPackaging = true
// 64비트 ARM 만 (위 abiFilters 와 같은 뜻, 플러그인 라이브러리까지 확실히 빼기)
excludes += setOf("lib/x86_64/**", "lib/x86/**", "lib/armeabi-v7a/**")
} }
} }
} }
@ -77,13 +47,3 @@ kotlin {
flutter { flutter {
source = "../.." source = "../.."
} }
// 다운로드 (앱 안 브라우저 · 다운로드 목록): Android 용 yt-dlp (Python 포함) · ffmpeg (영상 · 음성 합치기) · aria2c (토렌트)
// 실행 파일을 앱에 넣고 처음 켤 때 풀어 둔다. 실행은 Dart (YtDlpBackend · Aria2Backend) 가 Windows 와 같은 방식으로 한다.
dependencies {
implementation("io.github.junkfood02.youtubedl-android:library:0.18.1")
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1")
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
// 업데이트 APK 를 설치 화면에 넘기는 FileProvider
implementation("androidx.core:core:1.16.0")
}

View File

@ -1,7 +0,0 @@
# youtubedl-android (Android 다운로드): 코드 축소(R8)가 Jackson · 압축 풀기 클래스를 지우거나 이름을 바꾸면
# YoutubeDL 객체를 만들 때 ExceptionInInitializerError 가 난다 (youtubedl-android README 의 권장 규칙)
-keep class com.yausername.** { *; }
-keep class com.fasterxml.jackson.** { *; }
-keep class org.apache.commons.compress.archivers.zip.** { *; }
-dontwarn com.fasterxml.jackson.**
-dontwarn org.apache.commons.**

View File

@ -4,6 +4,4 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- 통합 테스트가 기기 안에 띄운 http://127.0.0.1 시험 페이지를 열 수 있게 (debug 빌드만) -->
<application android:usesCleartextTraffic="true"/>
</manifest> </manifest>

View File

@ -1,30 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- 동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위한 저장소 접근 -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- 앱이 화면에서 내려가도 다운로드 · MKV 만들기 · AI 자막을 계속하고 진행 알림을 보여 주기 위해 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- 환경 설정 > 최신 버전 확인: 받은 새 APK 로 설치 화면 열기 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application <application
android:label="JJ_MKVMaker" android:label="jj_mkvmaker"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher">
android:requestLegacyExternalStorage="true">
<!-- Android 16: 큰 화면 (태블릿) 에서도 환경 설정 > 화면 방향 (가로 고정 · 세로 고정) 을 따르게 -->
<property
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
android:value="true" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTop"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@ -38,101 +20,11 @@
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<!-- 앱 목록 · 홈 화면 아이콘은 아래 activity-alias (환경 설정 > 일반 > 앱 아이콘 에서 고른 것 하나만 켬) -->
<!-- 연결 프로그램: 파일 앱 등에서 동영상 "다음으로 열기" -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:mimeType="video/*"/>
</intent-filter>
<!-- 공유 메뉴: 동영상 하나 · 여러 개 -->
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<action android:name="android.intent.action.SEND_MULTIPLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="video/*"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- 아이콘이 여는 중계 화면 (MainActivity 를 열고 바로 닫힘: 아이콘을 바꿔도 앱 화면이 닫히지 않게) -->
<activity
android:name=".LauncherActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- 앱 아이콘 (고를 수 있는 것마다 하나, 처음에는 노랑만 켜짐) -->
<activity-alias
android:name=".IconYellow"
android:targetActivity=".LauncherActivity"
android:icon="@mipmap/ic_launcher_yellow"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<activity-alias
android:name=".IconBlack"
android:targetActivity=".LauncherActivity"
android:icon="@mipmap/ic_launcher_black"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<activity-alias
android:name=".IconFilmJj"
android:targetActivity=".LauncherActivity"
android:icon="@mipmap/ic_launcher_film_jj"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<activity-alias
android:name=".IconFilm"
android:targetActivity=".LauncherActivity"
android:icon="@mipmap/ic_launcher_film"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<activity-alias
android:name=".IconBlue"
android:targetActivity=".LauncherActivity"
android:icon="@mipmap/ic_launcher_blue"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<!-- 업데이트 APK 를 설치 화면에 넘기기 위한 파일 공유 (cache/updates 만) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/jj_file_paths"/>
</provider>
<service
android:name=".KeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync"/>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@ -1,101 +0,0 @@
package com.jj.jj_mkvmaker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
/// 다운로드 · MKV 만들기 · AI 자막이 진행 중일 때 앱이 화면에서 내려가도 Android 가 멈추지 않도록 하는
/// 포그라운드 서비스. 알림에 진행 상황을 보여 주고, 화면이 꺼져도 CPU 가 잠들지 않게 한다.
/// 실제 작업은 앱 (Dart) 이 같은 프로세스에서 계속한다. 작업이 모두 끝나면 앱이 멈춘다.
class KeepAliveService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
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)
running = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, n)
}
if (wakeLock == null) {
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "jj_mkvmaker:jobs")
.apply { acquire(6 * 60 * 60 * 1000L) }
}
return START_NOT_STICKY
}
/// Android 15 부터 dataSync 서비스는 하루 6시간까지 → 시간이 다 되면 멈춘다 (작업은 앱이 화면에 있으면 계속)
override fun onTimeout(startId: Int, fgsType: Int) {
stopSelf()
}
override fun onDestroy() {
running = false
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
companion object {
const val CHANNEL_ID = "jobs"
const val NOTIFICATION_ID = 1
const val EXTRA_TEXT = "text"
const val EXTRA_PROGRESS = "progress"
/// 서비스가 떠 있는지 (떠 있으면 알림만 바꾼다 - 백그라운드에서는 서비스를 새로 시작할 수 없음)
@Volatile
var running = false
fun update(context: Context, text: String, progress: Int) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, notification(context, text, progress))
}
private fun notification(context: Context, text: String, progress: Int): 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(
NotificationChannel(CHANNEL_ID, "작업 진행", NotificationManager.IMPORTANCE_LOW).apply {
description = "다운로드 · MKV 만들기 · AI 자막 진행 상황"
setShowBadge(false)
}
)
}
val open = PendingIntent.getActivity(
context, 0,
// 누르면 앱의 "작업 현황" 화면으로 (지금 하는 작업 · 진행률 · 대기열 · 다운로드)
Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_SHOW_JOBS)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, CHANNEL_ID)
} else {
@Suppress("DEPRECATION") Notification.Builder(context)
}
b.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("JJ_MKVMaker 작업 중")
.setContentText(text)
.setContentIntent(open)
.setOngoing(true)
.setOnlyAlertOnce(true)
if (progress in 0..100) b.setProgress(100, progress, false)
return b.build()
}
}
}

View File

@ -1,19 +0,0 @@
package com.jj.jj_mkvmaker
import android.app.Activity
import android.content.Intent
import android.os.Bundle
/// 앱 목록 · 홈 화면 아이콘 (activity-alias) 이 여는 중계 화면: MainActivity 를 열고 바로 닫는다.
/// MainActivity 작업이 alias 로 시작되면 환경 설정에서 아이콘을 바꿀 때 (그 alias 를 끄면) 앱 화면이 닫히므로.
class LauncherActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(Intent(this, MainActivity::class.java).apply {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
finish()
}
}

View File

@ -1,394 +1,5 @@
package com.jj.jj_mkvmaker package com.jj.jj_mkvmaker
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import com.yausername.aria2c.Aria2c
import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비 class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
companion object {
/// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다
const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS"
}
private var channel: MethodChannel? = null
/// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다)
private var pendingShowJobs = false
/// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다)
private var pendingOpen: List<Map<String, String?>> = emptyList()
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
pendingOpen = openedFiles(intent)
pendingShowJobs = intent?.action == ACTION_SHOW_JOBS
val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android")
channel = ch
ch.setMethodCallHandler { call, result ->
when (call.method) {
"takeOpenedFiles" -> {
result.success(pendingOpen)
pendingOpen = emptyList()
}
"takeShowJobs" -> {
result.success(pendingShowJobs)
pendingShowJobs = false
}
"hasAllFilesAccess" -> result.success(hasAllFilesAccess())
"requestAllFilesAccess" -> {
requestAllFilesAccess()
result.success(null)
}
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
"storageVolumes" -> result.success(storageVolumes())
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
"installApk" -> try {
installApk(call.argument<String>("path") ?: "")
result.success(null)
} catch (e: IllegalStateException) {
// 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다
result.error("PERMISSION", e.message ?: e.toString(), null)
} catch (e: Exception) {
result.error("INSTALL", e.message ?: e.toString(), null)
}
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
"setAppIcon" -> result.success(setAppIcon(call.argument<String>("id") ?: ""))
"keepAlive" -> {
keepAlive(call.argument<String>("text") ?: "", call.argument<Int>("progress") ?: -1)
result.success(null)
}
"stopKeepAlive" -> {
stopService(Intent(this, KeepAliveService::class.java))
result.success(null)
}
"downloadToolsInit" -> inBackground(result) { downloadToolsInit() }
"downloadToolsUpdate" -> inBackground(result) {
YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: ""
}
else -> result.notImplemented()
}
}
}
/// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다.
/// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다)
private fun installApk(path: String) {
val src = File(path)
if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
throw IllegalStateException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요")
}
// FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다
val dir = File(cacheDir, "updates").apply { mkdirs() }
val apk = File(dir, "update.apk")
if (src.absolutePath != apk.absolutePath) {
apk.delete()
if (!src.renameTo(apk)) {
src.copyTo(apk, overwrite = true)
src.delete()
}
}
val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk)
startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
/// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == ACTION_SHOW_JOBS) {
channel?.invokeMethod("showJobs", null)
return
}
val files = openedFiles(intent)
if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files)
}
/// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}]
private fun openedFiles(intent: Intent?): List<Map<String, String?>> {
if (intent == null) return emptyList()
val uris = mutableListOf<Uri>()
when (intent.action) {
Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) }
Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) }
Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) }
}
return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) }
}
@Suppress("DEPRECATION")
private fun intentStream(i: Intent): Uri? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableExtra(Intent.EXTRA_STREAM)
@Suppress("DEPRECATION")
private fun intentStreams(i: Intent): List<Uri>? =
if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
private fun displayName(uri: Uri): String? = try {
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
}
} catch (e: Exception) {
null
} ?: uri.lastPathSegment?.substringAfterLast('/')
/// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null.
private fun realPath(uri: Uri): String? {
fun ok(path: String?) = path != null && File(path).isFile
if (uri.scheme == "file") return uri.path?.takeIf { ok(it) }
if (uri.scheme != "content") return null
// 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4)
try {
if (android.provider.DocumentsContract.isDocumentUri(this, uri) &&
uri.authority == "com.android.externalstorage.documents"
) {
val id = android.provider.DocumentsContract.getDocumentId(uri)
val vol = id.substringBefore(':')
val rel = id.substringAfter(':', "")
val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol"
val path = "$base/$rel"
if (ok(path)) return path
}
} catch (e: Exception) {
}
// 2. 미디어 저장소 등이 알려 주는 경로 (_data)
try {
contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use {
if (it.moveToFirst()) {
val path = it.getString(0)
if (ok(path)) return path
}
}
} catch (e: Exception) {
}
// 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서)
// 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만)
val size = try {
contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use {
if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L
} ?: -1L
} catch (e: Exception) {
-1L
}
val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() }
val roots = storageVolumes().map { it["path"] as String }
for (i in segs.indices) {
val rest = segs.drop(i).joinToString("/")
for (root in roots) {
val f = File(root, rest)
if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath
}
// 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…)
val abs = "/$rest"
if (abs.startsWith("/storage/") && ok(abs)) return abs
}
return null
}
/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}]
private fun storageVolumes(): List<Map<String, Any>> {
val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager
val out = mutableListOf<Map<String, Any>>()
for (v in sm.storageVolumes) {
if (v.state != Environment.MEDIA_MOUNTED) continue
val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
v.directory
} else {
try {
v.javaClass.getMethod("getPathFile").invoke(v) as File?
} catch (e: Exception) {
null
}
}
if (dir == null) continue
out.add(
mapOf(
"path" to dir.absolutePath,
"label" to (v.getDescription(this) ?: dir.name),
"removable" to v.isRemovable,
)
)
}
return out
}
/// 웹 주소를 기기의 기본 브라우저로
private fun openUrl(url: String): Boolean = try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
true
} catch (e: Exception) {
false
}
/// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false.
/// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로)
private fun setAppIcon(id: String): Boolean {
val ids = listOf("yellow", "black", "film_jj", "film", "blue")
if (id !in ids) return false
val pm = packageManager
for (i in ids) {
val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) }
val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
val cn = android.content.ComponentName(this, name)
if (pm.getComponentEnabledSetting(cn) != state) {
pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP)
}
}
return true
}
private fun openFolder(path: String): Boolean {
// 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...")
val root = Environment.getExternalStorageDirectory().path
val (volumeId, rel) = when {
path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/')
path.startsWith("/storage/") -> {
val rest = path.removePrefix("/storage/")
rest.substringBefore('/') to rest.substringAfter('/', "").trim('/')
}
else -> return false
}
// 1. 삼성 "내 파일": 그 폴더로 바로 연다
try {
startActivity(
Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES")
.setPackage("com.sec.android.app.myfiles")
.putExtra("samsung.myfiles.intent.extra.START_PATH", path)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return true
} catch (e: Exception) {
}
// 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다
val uri = android.provider.DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents", "$volumeId:$rel"
)
for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) {
try {
startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR)
.apply { if (pkg != null) setPackage(pkg) }
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
)
return true
} catch (e: Exception) {
}
}
return false
}
/// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속)
private var askedNotifications = false
private fun keepAlive(text: String, progress: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
askedNotifications = true
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2)
}
if (KeepAliveService.running) {
KeepAliveService.update(this, text, progress)
return
}
val i = Intent(this, KeepAliveService::class.java)
.putExtra(KeepAliveService.EXTRA_TEXT, text)
.putExtra(KeepAliveService.EXTRA_PROGRESS, progress)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(i) else startService(i)
} catch (e: Exception) {
// 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다
android.util.Log.w("jj_mkvmaker", "keepAlive", e)
}
}
/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서
private fun inBackground(result: MethodChannel.Result, work: () -> Any?) {
Thread {
try {
val value = work()
runOnUiThread { result.success(value) }
} catch (e: Throwable) {
android.util.Log.e("jj_mkvmaker", "download tools", e)
val cause = generateSequence(e) { it.cause }.last()
runOnUiThread { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) }
}
}.start()
}
/// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다.
/// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다.
private fun downloadToolsInit(): Map<String, Any> {
YoutubeDL.init(applicationContext)
FFmpeg.init(applicationContext)
Aria2c.init(applicationContext)
val bin = applicationInfo.nativeLibraryDir
val base = File(noBackupFilesDir, YoutubeDL.baseName)
val packages = File(base, "packages")
val python = File(packages, "python").absolutePath
val ffmpeg = File(packages, "ffmpeg").absolutePath
val aria2c = File(packages, "aria2c").absolutePath
val cert = "$python/usr/etc/tls/cert.pem"
return mapOf(
"python" to "$bin/libpython.so",
"ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath,
"ffmpeg" to "$bin/libffmpeg.so",
"quickjs" to "$bin/libqjs.so",
"aria2c" to "$bin/libaria2c.so",
"cert" to cert,
"version" to (YoutubeDL.versionName(applicationContext) ?: ""),
"env" to mapOf(
"LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib",
"SSL_CERT_FILE" to cert,
"PATH" to (System.getenv("PATH") ?: "") + ":" + bin,
"PYTHONHOME" to "$python/usr",
"HOME" to "$python/usr",
"TMPDIR" to cacheDir.absolutePath,
),
)
}
private fun hasAllFilesAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
private fun requestAllFilesAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName")))
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
}
} else {
requestPermissions(
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1
)
}
}
}

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_bg"/>
<foreground android:drawable="@mipmap/ic_launcher_fg"/>
<monochrome android:drawable="@mipmap/ic_launcher_fg"/>
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_black_bg"/>
<foreground android:drawable="@mipmap/ic_launcher_black_fg"/>
<monochrome android:drawable="@mipmap/ic_launcher_black_fg"/>
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_blue_bg"/>
<foreground android:drawable="@mipmap/ic_launcher_blue_fg"/>
<monochrome android:drawable="@mipmap/ic_launcher_blue_fg"/>
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_film_bg"/>
<foreground android:drawable="@mipmap/ic_launcher_film_fg"/>
<monochrome android:drawable="@mipmap/ic_launcher_film_fg"/>
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_film_jj_bg"/>
<foreground android:drawable="@mipmap/ic_launcher_film_jj_fg"/>
<monochrome android:drawable="@mipmap/ic_launcher_film_jj_fg"/>
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_yellow_bg"/>
<foreground android:drawable="@mipmap/ic_launcher_yellow_fg"/>
<monochrome android:drawable="@mipmap/ic_launcher_yellow_fg"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Some files were not shown because too many files have changed in this diff Show More