...
This commit is contained in:
parent
964655af7f
commit
162d2b7563
@ -229,6 +229,11 @@ dependencies {
|
|||||||
// (선택) CameraX 확장 라이브러리 (보케, HDR 등)
|
// (선택) CameraX 확장 라이브러리 (보케, HDR 등)
|
||||||
implementation("androidx.camera:camera-extensions:$camerax_version")
|
implementation("androidx.camera:camera-extensions:$camerax_version")
|
||||||
|
|
||||||
|
implementation("com.google.mlkit:translate:17.0.3") // 버전 살짝 업그레이드 권장
|
||||||
|
implementation("com.google.mlkit:language-id:17.0.5")
|
||||||
|
implementation("com.google.android.gms:play-services-base:18.5.0")
|
||||||
|
implementation("com.google.mlkit:common:18.11.0")
|
||||||
|
|
||||||
constraints {
|
constraints {
|
||||||
// ⚠️ 이 버전을 프로젝트 루트의 build.gradle.kts에 정의된 kotlinVersion 값과 정확히 일치시키세요.
|
// ⚠️ 이 버전을 프로젝트 루트의 build.gradle.kts에 정의된 kotlinVersion 값과 정확히 일치시키세요.
|
||||||
val targetKotlinVersion = "2.0.20"
|
val targetKotlinVersion = "2.0.20"
|
||||||
|
|||||||
@ -62,6 +62,14 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.google.android.gms" />
|
||||||
|
<intent>
|
||||||
|
<action android:name="com.google.android.gms.actions.SEARCH_ACTION" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
<application
|
<application
|
||||||
android:name=".LunaticLauncher"
|
android:name=".LunaticLauncher"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
@ -105,9 +113,12 @@
|
|||||||
<data android:mimeType="*/*"/>
|
<data android:mimeType="*/*"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".home.TranslatorActivity"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".player.PlayerActivity"
|
android:name=".player.PlayerActivity"
|
||||||
|
android:theme="@style/Theme.Player"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
|
||||||
android:screenOrientation="sensor"
|
android:screenOrientation="sensor"
|
||||||
@ -233,6 +244,8 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service android:name="bums.lunatic.launcher.workers.LocationUpdateService" />
|
<service android:name="bums.lunatic.launcher.workers.LocationUpdateService" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
|
android:value="ocr,langid,translate" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@ -10,7 +10,6 @@
|
|||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||||
|
|
||||||
// [1. 커스텀 I/O 함수]
|
|
||||||
static int custom_read_packet(void *opaque, uint8_t *buf, int buf_size) {
|
static int custom_read_packet(void *opaque, uint8_t *buf, int buf_size) {
|
||||||
int fd = (int)(intptr_t)opaque;
|
int fd = (int)(intptr_t)opaque;
|
||||||
int ret = read(fd, buf, buf_size);
|
int ret = read(fd, buf, buf_size);
|
||||||
@ -31,14 +30,13 @@ static int64_t custom_seek_packet(void *opaque, int64_t offset, int whence) {
|
|||||||
return ret < 0 ? AVERROR(errno) : ret;
|
return ret < 0 ? AVERROR(errno) : ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
// [2. 생성자 및 초기화]
|
|
||||||
PlayerEngine::PlayerEngine(JavaVM* vm, jobject listenerObj) : jvm_(vm) {
|
PlayerEngine::PlayerEngine(JavaVM* vm, jobject listenerObj) : jvm_(vm) {
|
||||||
JNIEnv* env;
|
JNIEnv* env;
|
||||||
jvm_->GetEnv((void**)&env, JNI_VERSION_1_6);
|
jvm_->GetEnv((void**)&env, JNI_VERSION_1_6);
|
||||||
listenerObj_ = env->NewGlobalRef(listenerObj);
|
listenerObj_ = env->NewGlobalRef(listenerObj);
|
||||||
jclass clazz = env->GetObjectClass(listenerObj_);
|
jclass clazz = env->GetObjectClass(listenerObj_);
|
||||||
|
|
||||||
auto getMethod = [&](const char* name, const char* sig) -> jmethodID {
|
auto safeGetMethod = [&](const char* name, const char* sig) -> jmethodID {
|
||||||
jmethodID id = env->GetMethodID(clazz, name, sig);
|
jmethodID id = env->GetMethodID(clazz, name, sig);
|
||||||
if (env->ExceptionCheck()) {
|
if (env->ExceptionCheck()) {
|
||||||
env->ExceptionClear();
|
env->ExceptionClear();
|
||||||
@ -48,73 +46,44 @@ PlayerEngine::PlayerEngine(JavaVM* vm, jobject listenerObj) : jvm_(vm) {
|
|||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
subtitleMethodId_ = getMethod("onSubtitleTextDecoded", "(Ljava/lang/String;)V");
|
subtitleMethodId_ = safeGetMethod("onSubtitleTextDecoded", "(Ljava/lang/String;)V");
|
||||||
videoSizeMethodId_ = getMethod("onVideoSizeChanged", "(II)V");
|
videoSizeMethodId_ = safeGetMethod("onVideoSizeChanged", "(II)V");
|
||||||
|
preparedMethodId_ = safeGetMethod("onNativePrepared", "()V");
|
||||||
|
errorMethodId_ = safeGetMethod("onNativeError", "(ILjava/lang/String;)V");
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerEngine::~PlayerEngine() {
|
PlayerEngine::~PlayerEngine() {
|
||||||
stop();
|
stop();
|
||||||
|
if (prepareThread_.joinable()) prepareThread_.join(); // 준비 스레드 대기
|
||||||
if (listenerObj_) {
|
if (listenerObj_) {
|
||||||
JNIEnv* env;
|
JNIEnv* env; jvm_->GetEnv((void**)&env, JNI_VERSION_1_6);
|
||||||
jvm_->GetEnv((void**)&env, JNI_VERSION_1_6);
|
|
||||||
env->DeleteGlobalRef(listenerObj_);
|
env->DeleteGlobalRef(listenerObj_);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlayerEngine::setDataSource(int videoFd, const std::string& subtitlePath) {
|
void PlayerEngine::setDataSource(int videoFd, int subtitleFd) {
|
||||||
videoFd_ = videoFd;
|
videoFd_ = videoFd;
|
||||||
subtitlePath_ = subtitlePath;
|
subtitleFd_ = subtitleFd;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlayerEngine::play(ANativeWindow* window) {
|
void PlayerEngine::prepareAsync() {
|
||||||
if (isPlaying_) {
|
if (isPrepared_ || prepareThread_.joinable()) return;
|
||||||
isPaused_ = false; // 💡 이미 재생 중이면 일시정지만 해제
|
prepareThread_ = std::thread(&PlayerEngine::prepareInternal, this);
|
||||||
return;
|
|
||||||
}
|
|
||||||
window_ = window;
|
|
||||||
ANativeWindow_acquire(window_);
|
|
||||||
isPlaying_ = true;
|
|
||||||
renderThread_ = std::thread(&PlayerEngine::renderLoop, this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlayerEngine::stop() {
|
void PlayerEngine::prepareInternal() {
|
||||||
if (!isPlaying_) return;
|
|
||||||
isPlaying_ = false;
|
|
||||||
if (renderThread_.joinable()) renderThread_.join();
|
|
||||||
if (window_) { ANativeWindow_release(window_); window_ = nullptr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
void PlayerEngine::seekBy(double seconds) {
|
|
||||||
seekTargetOffset_ = seconds;
|
|
||||||
seekReq_ = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void PlayerEngine::setSpeed(float speed) {
|
|
||||||
playbackSpeed_ = speed > 0.0f ? speed : 1.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
void PlayerEngine::sendSubtitleToKotlin(const char* text) {
|
|
||||||
if (!jvm_ || !listenerObj_ || !subtitleMethodId_ || !text) return;
|
|
||||||
JNIEnv* env;
|
JNIEnv* env;
|
||||||
bool attached = false;
|
jvm_->AttachCurrentThread(&env, nullptr);
|
||||||
if (jvm_->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) {
|
|
||||||
jvm_->AttachCurrentThread(&env, nullptr);
|
|
||||||
attached = true;
|
|
||||||
}
|
|
||||||
jstring jText = env->NewStringUTF(text);
|
|
||||||
env->CallVoidMethod(listenerObj_, subtitleMethodId_, jText);
|
|
||||||
env->DeleteLocalRef(jText);
|
|
||||||
if (attached) jvm_->DetachCurrentThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
void PlayerEngine::pause() {
|
auto sendError = [&](int code, const char* msg) {
|
||||||
|
LOGE("Prepare Error [%d]: %s", code, msg);
|
||||||
isPaused_ = true;
|
if (errorMethodId_) {
|
||||||
}
|
jstring jMsg = env->NewStringUTF(msg);
|
||||||
|
env->CallVoidMethod(listenerObj_, errorMethodId_, code, jMsg);
|
||||||
// [3. 메인 렌더링 루프]
|
env->DeleteLocalRef(jMsg);
|
||||||
void PlayerEngine::renderLoop() {
|
}
|
||||||
LOGI("Player render loop started (AAudio Mode)");
|
jvm_->DetachCurrentThread();
|
||||||
|
};
|
||||||
|
|
||||||
int avio_buffer_size = 32768;
|
int avio_buffer_size = 32768;
|
||||||
uint8_t* avio_buffer = (uint8_t*)av_malloc(avio_buffer_size);
|
uint8_t* avio_buffer = (uint8_t*)av_malloc(avio_buffer_size);
|
||||||
@ -122,30 +91,38 @@ void PlayerEngine::renderLoop() {
|
|||||||
fmt_ctx_ = avformat_alloc_context();
|
fmt_ctx_ = avformat_alloc_context();
|
||||||
fmt_ctx_->pb = avio_ctx;
|
fmt_ctx_->pb = avio_ctx;
|
||||||
|
|
||||||
if (avformat_open_input(&fmt_ctx_, nullptr, nullptr, nullptr) < 0) return;
|
if (avformat_open_input(&fmt_ctx_, nullptr, nullptr, nullptr) < 0) {
|
||||||
avformat_find_stream_info(fmt_ctx_, nullptr);
|
return sendError(-1001, "Failed to open video file. Invalid FD or Format.");
|
||||||
|
}
|
||||||
|
if (avformat_find_stream_info(fmt_ctx_, nullptr) < 0) {
|
||||||
|
return sendError(-1002, "Failed to retrieve stream information.");
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitle_tracks_info_ = "";
|
||||||
for (unsigned int i = 0; i < fmt_ctx_->nb_streams; i++) {
|
for (unsigned int i = 0; i < fmt_ctx_->nb_streams; i++) {
|
||||||
auto type = fmt_ctx_->streams[i]->codecpar->codec_type;
|
auto type = fmt_ctx_->streams[i]->codecpar->codec_type;
|
||||||
if (type == AVMEDIA_TYPE_VIDEO && video_stream_idx_ < 0) video_stream_idx_ = i;
|
if (type == AVMEDIA_TYPE_VIDEO && video_stream_idx_ < 0) video_stream_idx_ = i;
|
||||||
else if (type == AVMEDIA_TYPE_SUBTITLE && sub_stream_idx_ < 0) sub_stream_idx_ = i;
|
|
||||||
else if (type == AVMEDIA_TYPE_AUDIO && audio_stream_idx_ < 0) audio_stream_idx_ = i;
|
else if (type == AVMEDIA_TYPE_AUDIO && audio_stream_idx_ < 0) audio_stream_idx_ = i;
|
||||||
|
else if (type == AVMEDIA_TYPE_SUBTITLE) {
|
||||||
|
AVDictionaryEntry *lang = av_dict_get(fmt_ctx_->streams[i]->metadata, "language", nullptr, 0);
|
||||||
|
AVDictionaryEntry *title = av_dict_get(fmt_ctx_->streams[i]->metadata, "title", nullptr, 0);
|
||||||
|
std::string trackName = "Track " + std::to_string(i);
|
||||||
|
if (title) trackName = title->value;
|
||||||
|
else if (lang) trackName = lang->value;
|
||||||
|
subtitle_tracks_info_ += std::to_string(i) + ":" + trackName + ",";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비디오 초기화
|
|
||||||
if (video_stream_idx_ >= 0) {
|
if (video_stream_idx_ >= 0) {
|
||||||
auto par = fmt_ctx_->streams[video_stream_idx_]->codecpar;
|
auto par = fmt_ctx_->streams[video_stream_idx_]->codecpar;
|
||||||
auto codec = avcodec_find_decoder(par->codec_id);
|
auto codec = avcodec_find_decoder(par->codec_id);
|
||||||
video_codec_ctx_ = avcodec_alloc_context3(codec);
|
video_codec_ctx_ = avcodec_alloc_context3(codec);
|
||||||
avcodec_parameters_to_context(video_codec_ctx_, par);
|
avcodec_parameters_to_context(video_codec_ctx_, par);
|
||||||
if (avcodec_open2(video_codec_ctx_, codec, nullptr) == 0) {
|
if (avcodec_open2(video_codec_ctx_, codec, nullptr) == 0 && videoSizeMethodId_) {
|
||||||
JNIEnv* env; jvm_->AttachCurrentThread(&env, nullptr);
|
env->CallVoidMethod(listenerObj_, videoSizeMethodId_, video_codec_ctx_->width, video_codec_ctx_->height);
|
||||||
if (videoSizeMethodId_) env->CallVoidMethod(listenerObj_, videoSizeMethodId_, video_codec_ctx_->width, video_codec_ctx_->height);
|
|
||||||
jvm_->DetachCurrentThread();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💡 [AAudio 초기화 복구]
|
|
||||||
if (audio_stream_idx_ >= 0) {
|
if (audio_stream_idx_ >= 0) {
|
||||||
auto par = fmt_ctx_->streams[audio_stream_idx_]->codecpar;
|
auto par = fmt_ctx_->streams[audio_stream_idx_]->codecpar;
|
||||||
auto codec = avcodec_find_decoder(par->codec_id);
|
auto codec = avcodec_find_decoder(par->codec_id);
|
||||||
@ -162,33 +139,99 @@ void PlayerEngine::renderLoop() {
|
|||||||
AAudioStreamBuilder_setChannelCount(builder, 2);
|
AAudioStreamBuilder_setChannelCount(builder, 2);
|
||||||
AAudioStreamBuilder_setSampleRate(builder, 48000);
|
AAudioStreamBuilder_setSampleRate(builder, 48000);
|
||||||
AAudioStreamBuilder_openStream(builder, &audio_stream_);
|
AAudioStreamBuilder_openStream(builder, &audio_stream_);
|
||||||
AAudioStream_requestStart(audio_stream_);
|
|
||||||
|
// 💡 1. 버퍼 크기를 가용 가능한 최대치로 늘려서 비디오 렌더링 딜레이에 대비합니다.
|
||||||
|
AAudioStream_setBufferSizeInFrames(audio_stream_, AAudioStream_getBufferCapacityInFrames(audio_stream_));
|
||||||
|
|
||||||
|
// 💡 2. 여기서 호출하던 AAudioStream_requestStart(audio_stream_); 를 삭제합니다! (대기 상태로 둠)
|
||||||
|
|
||||||
AAudioStreamBuilder_delete(builder);
|
AAudioStreamBuilder_delete(builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPrepared_ = true;
|
||||||
|
if (preparedMethodId_) env->CallVoidMethod(listenerObj_, preparedMethodId_);
|
||||||
|
jvm_->DetachCurrentThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerEngine::play(ANativeWindow* window) {
|
||||||
|
if (!isPrepared_) return;
|
||||||
|
if (isPlaying_) {
|
||||||
|
isPaused_ = false;
|
||||||
|
// 💡 일시정지가 풀릴 때 오디오도 다시 시작
|
||||||
|
if (audio_stream_) AAudioStream_requestStart(audio_stream_);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window_ = window;
|
||||||
|
ANativeWindow_acquire(window_);
|
||||||
|
isPlaying_ = true;
|
||||||
|
isPaused_ = false;
|
||||||
|
|
||||||
|
// 💡 처음 재생을 시작할 때 비로소 오디오 엔진을 가동합니다.
|
||||||
|
if (audio_stream_) AAudioStream_requestStart(audio_stream_);
|
||||||
|
|
||||||
|
renderThread_ = std::thread(&PlayerEngine::renderLoop, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerEngine::pause() {
|
||||||
|
isPaused_ = true;
|
||||||
|
// 💡 영상이 일시정지되면 오디오 버퍼도 소모되지 않게 멈춰줍니다.
|
||||||
|
if (audio_stream_) AAudioStream_requestPause(audio_stream_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerEngine::stop() {
|
||||||
|
if (!isPlaying_) return;
|
||||||
|
isPlaying_ = false;
|
||||||
|
if (renderThread_.joinable()) renderThread_.join();
|
||||||
|
if (window_) { ANativeWindow_release(window_); window_ = nullptr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerEngine::seekBy(double seconds) {
|
||||||
|
seekTargetOffset_ = seconds;
|
||||||
|
seekReq_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerEngine::setSpeed(float speed) { playbackSpeed_ = speed > 0.0f ? speed : 1.0f; }
|
||||||
|
|
||||||
|
void PlayerEngine::sendSubtitleToKotlin(const char* text) {
|
||||||
|
if (!jvm_ || !listenerObj_ || !subtitleMethodId_ || !text) return;
|
||||||
|
JNIEnv* env; bool attached = false;
|
||||||
|
if (jvm_->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) { jvm_->AttachCurrentThread(&env, nullptr); attached = true; }
|
||||||
|
jstring jText = env->NewStringUTF(text);
|
||||||
|
env->CallVoidMethod(listenerObj_, subtitleMethodId_, jText);
|
||||||
|
env->DeleteLocalRef(jText);
|
||||||
|
if (attached) jvm_->DetachCurrentThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerEngine::renderLoop() {
|
||||||
|
LOGI("Player render loop started (AAudio Mode)");
|
||||||
AVFrame* frame = av_frame_alloc();
|
AVFrame* frame = av_frame_alloc();
|
||||||
AVPacket* pkt = av_packet_alloc();
|
AVPacket* pkt = av_packet_alloc();
|
||||||
int last_win_w = 0, last_win_h = 0;
|
int last_win_w = 0, last_win_h = 0;
|
||||||
|
|
||||||
while (isPlaying_) {
|
while (isPlaying_) {
|
||||||
if (isPaused_) {
|
if (isPaused_) {
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||||
continue; // 💡 일시정지 중이면 루프를 돌며 대기만 함
|
continue;
|
||||||
}
|
}
|
||||||
// [Seek 요청]
|
|
||||||
if (seekReq_) {
|
if (seekReq_) {
|
||||||
av_seek_frame(fmt_ctx_, -1, (currentPosSec_ + seekTargetOffset_) * AV_TIME_BASE, AVSEEK_FLAG_BACKWARD);
|
av_seek_frame(fmt_ctx_, -1, (currentPosSec_ + seekTargetOffset_) * AV_TIME_BASE, AVSEEK_FLAG_BACKWARD);
|
||||||
if (video_codec_ctx_) avcodec_flush_buffers(video_codec_ctx_);
|
if (video_codec_ctx_) avcodec_flush_buffers(video_codec_ctx_);
|
||||||
if (audio_codec_ctx_) avcodec_flush_buffers(audio_codec_ctx_);
|
if (audio_codec_ctx_) avcodec_flush_buffers(audio_codec_ctx_);
|
||||||
if (audio_stream_) { AAudioStream_requestFlush(audio_stream_); AAudioStream_requestStart(audio_stream_); }
|
|
||||||
|
if (audio_stream_) {
|
||||||
|
// 💡 안전한 버퍼 초기화를 위해 정지 -> 비움 -> 재시작 순서로 호출
|
||||||
|
AAudioStream_requestPause(audio_stream_);
|
||||||
|
AAudioStream_requestFlush(audio_stream_);
|
||||||
|
AAudioStream_requestStart(audio_stream_);
|
||||||
|
}
|
||||||
seekReq_ = false;
|
seekReq_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
float currentSpeed = playbackSpeed_.load();
|
float currentSpeed = playbackSpeed_.load();
|
||||||
if (av_read_frame(fmt_ctx_, pkt) < 0) break;
|
if (av_read_frame(fmt_ctx_, pkt) < 0) break;
|
||||||
|
|
||||||
// [비디오 처리]
|
|
||||||
if (pkt->stream_index == video_stream_idx_) {
|
if (pkt->stream_index == video_stream_idx_) {
|
||||||
avcodec_send_packet(video_codec_ctx_, pkt);
|
avcodec_send_packet(video_codec_ctx_, pkt);
|
||||||
while (avcodec_receive_frame(video_codec_ctx_, frame) == 0) {
|
while (avcodec_receive_frame(video_codec_ctx_, frame) == 0) {
|
||||||
@ -209,58 +252,40 @@ void PlayerEngine::renderLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 💡 배속 중이거나 오디오가 없으면 Sleep으로 속도 직접 조절
|
|
||||||
if (currentSpeed != 1.0f || audio_stream_idx_ < 0) {
|
if (currentSpeed != 1.0f || audio_stream_idx_ < 0) {
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds((int)(16/currentSpeed)));
|
std::this_thread::sleep_for(std::chrono::milliseconds((int)(16/currentSpeed)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// [오디오 처리]
|
|
||||||
else if (pkt->stream_index == audio_stream_idx_) {
|
else if (pkt->stream_index == audio_stream_idx_) {
|
||||||
if (currentSpeed == 1.0f) { // 정배속일 때만 재생
|
if (currentSpeed == 1.0f) {
|
||||||
avcodec_send_packet(audio_codec_ctx_, pkt);
|
avcodec_send_packet(audio_codec_ctx_, pkt);
|
||||||
while (avcodec_receive_frame(audio_codec_ctx_, frame) == 0) {
|
while (avcodec_receive_frame(audio_codec_ctx_, frame) == 0) {
|
||||||
if (swr_ctx_ && audio_stream_) {
|
if (swr_ctx_ && audio_stream_) {
|
||||||
// 1. 출력될 샘플 수 계산
|
|
||||||
int out_samples = swr_get_out_samples(swr_ctx_, frame->nb_samples);
|
int out_samples = swr_get_out_samples(swr_ctx_, frame->nb_samples);
|
||||||
|
|
||||||
// 2. 버퍼 크기는 바이트 단위 (샘플 수 * 2채널 * 2바이트(16비트))
|
|
||||||
uint8_t* out_buf = (uint8_t*)malloc(out_samples * 4);
|
uint8_t* out_buf = (uint8_t*)malloc(out_samples * 4);
|
||||||
|
int converted_samples = swr_convert(swr_ctx_, &out_buf, out_samples, (const uint8_t**)frame->data, frame->nb_samples);
|
||||||
// 3. 변환 수행 (실제로 변환된 정확한 샘플 수를 반환받음)
|
|
||||||
int converted_samples = swr_convert(swr_ctx_, &out_buf, out_samples,
|
|
||||||
(const uint8_t**)frame->data, frame->nb_samples);
|
|
||||||
|
|
||||||
if (converted_samples > 0) {
|
if (converted_samples > 0) {
|
||||||
// 💡 [핵심 수정] out_size(바이트) 대신 converted_samples(샘플 수)를 전달해야 함
|
|
||||||
// 세 번째 인자는 'samples' 단위여야 합니다.
|
|
||||||
AAudioStream_write(audio_stream_, out_buf, converted_samples, 1000000000);
|
AAudioStream_write(audio_stream_, out_buf, converted_samples, 1000000000);
|
||||||
}
|
}
|
||||||
free(out_buf);
|
free(out_buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (pkt->stream_index == sub_stream_idx_) {
|
}
|
||||||
AVSubtitle sub;
|
else if (pkt->stream_index == current_sub_stream_idx_) {
|
||||||
int got_sub = 0;
|
AVSubtitle sub; int got_sub = 0;
|
||||||
// 자막 패킷 디코딩
|
|
||||||
avcodec_decode_subtitle2(sub_codec_ctx_, &sub, &got_sub, pkt);
|
avcodec_decode_subtitle2(sub_codec_ctx_, &sub, &got_sub, pkt);
|
||||||
if (got_sub) {
|
if (got_sub) {
|
||||||
for (unsigned int i = 0; i < sub.num_rects; i++) {
|
for (unsigned int i = 0; i < sub.num_rects; i++) {
|
||||||
// 일반 텍스트 자막 또는 ASS/SSA 스타일 자막 처리
|
if (sub.rects[i]->type == SUBTITLE_TEXT && sub.rects[i]->text) sendSubtitleToKotlin(sub.rects[i]->text);
|
||||||
if (sub.rects[i]->type == SUBTITLE_TEXT && sub.rects[i]->text) {
|
else if (sub.rects[i]->type == SUBTITLE_ASS && sub.rects[i]->ass) sendSubtitleToKotlin(sub.rects[i]->ass);
|
||||||
sendSubtitleToKotlin(sub.rects[i]->text);
|
|
||||||
} else if (sub.rects[i]->type == SUBTITLE_ASS && sub.rects[i]->ass) {
|
|
||||||
// ASS 자막의 경우 마크업 태그가 포함될 수 있음
|
|
||||||
sendSubtitleToKotlin(sub.rects[i]->ass);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
avsubtitle_free(&sub); // 메모리 해제 필수
|
avsubtitle_free(&sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
av_packet_unref(pkt);
|
av_packet_unref(pkt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자원 해제
|
|
||||||
if (audio_stream_) { AAudioStream_requestStop(audio_stream_); AAudioStream_close(audio_stream_); audio_stream_ = nullptr; }
|
if (audio_stream_) { AAudioStream_requestStop(audio_stream_); AAudioStream_close(audio_stream_); audio_stream_ = nullptr; }
|
||||||
av_frame_free(&frame);
|
av_frame_free(&frame);
|
||||||
av_packet_free(&pkt);
|
av_packet_free(&pkt);
|
||||||
@ -270,4 +295,7 @@ void PlayerEngine::renderLoop() {
|
|||||||
if (audio_codec_ctx_) avcodec_free_context(&audio_codec_ctx_);
|
if (audio_codec_ctx_) avcodec_free_context(&audio_codec_ctx_);
|
||||||
if (fmt_ctx_) avformat_close_input(&fmt_ctx_);
|
if (fmt_ctx_) avformat_close_input(&fmt_ctx_);
|
||||||
LOGI("Player loop finished gracefully.");
|
LOGI("Player loop finished gracefully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string PlayerEngine::getSubtitleTracks() { return subtitle_tracks_info_; }
|
||||||
|
void PlayerEngine::setSubtitleTrack(int streamIndex) { current_sub_stream_idx_ = streamIndex; }
|
||||||
@ -4,7 +4,7 @@
|
|||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <android/native_window.h>
|
#include <android/native_window.h>
|
||||||
#include <aaudio/AAudio.h> // 💡 AAudio 추가
|
#include <aaudio/AAudio.h>
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include <libavformat/avformat.h>
|
#include <libavformat/avformat.h>
|
||||||
@ -20,22 +20,31 @@ public:
|
|||||||
PlayerEngine(JavaVM* vm, jobject listenerObj);
|
PlayerEngine(JavaVM* vm, jobject listenerObj);
|
||||||
~PlayerEngine();
|
~PlayerEngine();
|
||||||
|
|
||||||
void setDataSource(int videoFd, const std::string& subtitlePath);
|
void setDataSource(int videoFd, int subtitleFd);
|
||||||
|
void prepareAsync(); // 💡 비동기 준비 시작 함수
|
||||||
void play(ANativeWindow* window);
|
void play(ANativeWindow* window);
|
||||||
void pause();
|
void pause();
|
||||||
void stop();
|
void stop();
|
||||||
void seekBy(double seconds);
|
void seekBy(double seconds);
|
||||||
void setSpeed(float speed);
|
void setSpeed(float speed);
|
||||||
|
double getCurrentPosition() const { return currentPosSec_; }
|
||||||
|
|
||||||
|
std::string getSubtitleTracks();
|
||||||
|
void setSubtitleTrack(int streamIndex);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void prepareInternal(); // 💡 백그라운드 준비 스레드
|
||||||
void renderLoop();
|
void renderLoop();
|
||||||
void sendSubtitleToKotlin(const char* text);
|
void sendSubtitleToKotlin(const char* text);
|
||||||
|
|
||||||
|
|
||||||
int videoFd_ = -1;
|
int videoFd_ = -1;
|
||||||
std::string subtitlePath_;
|
int subtitleFd_ = -1;
|
||||||
|
|
||||||
|
std::atomic<bool> isPrepared_{false};
|
||||||
std::atomic<bool> isPlaying_{false};
|
std::atomic<bool> isPlaying_{false};
|
||||||
std::atomic<bool> isPaused_{false}; // 💡 일시정지 플래그 추가
|
std::atomic<bool> isPaused_{false};
|
||||||
|
|
||||||
|
std::thread prepareThread_;
|
||||||
std::thread renderThread_;
|
std::thread renderThread_;
|
||||||
ANativeWindow* window_ = nullptr;
|
ANativeWindow* window_ = nullptr;
|
||||||
|
|
||||||
@ -44,6 +53,9 @@ private:
|
|||||||
std::atomic<float> playbackSpeed_{1.0f};
|
std::atomic<float> playbackSpeed_{1.0f};
|
||||||
double currentPosSec_ = 0.0;
|
double currentPosSec_ = 0.0;
|
||||||
|
|
||||||
|
int current_sub_stream_idx_ = -1;
|
||||||
|
std::string subtitle_tracks_info_;
|
||||||
|
|
||||||
AVFormatContext* fmt_ctx_ = nullptr;
|
AVFormatContext* fmt_ctx_ = nullptr;
|
||||||
AVCodecContext* video_codec_ctx_ = nullptr;
|
AVCodecContext* video_codec_ctx_ = nullptr;
|
||||||
AVCodecContext* sub_codec_ctx_ = nullptr;
|
AVCodecContext* sub_codec_ctx_ = nullptr;
|
||||||
@ -55,10 +67,12 @@ private:
|
|||||||
int sub_stream_idx_ = -1;
|
int sub_stream_idx_ = -1;
|
||||||
int audio_stream_idx_ = -1;
|
int audio_stream_idx_ = -1;
|
||||||
|
|
||||||
AAudioStream* audio_stream_ = nullptr; // 💡 AAudio 복구
|
AAudioStream* audio_stream_ = nullptr;
|
||||||
|
|
||||||
JavaVM* jvm_ = nullptr;
|
JavaVM* jvm_ = nullptr;
|
||||||
jobject listenerObj_ = nullptr;
|
jobject listenerObj_ = nullptr;
|
||||||
jmethodID subtitleMethodId_ = nullptr;
|
jmethodID subtitleMethodId_ = nullptr;
|
||||||
jmethodID videoSizeMethodId_ = nullptr;
|
jmethodID videoSizeMethodId_ = nullptr;
|
||||||
|
jmethodID preparedMethodId_ = nullptr; // 💡 JNI 콜백 ID 추가
|
||||||
|
jmethodID errorMethodId_ = nullptr; // 💡 JNI 에러 콜백 ID 추가
|
||||||
};
|
};
|
||||||
@ -1,24 +1,16 @@
|
|||||||
//
|
|
||||||
// Created by JIBUM HAN on 2026. 4. 9..
|
|
||||||
//
|
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <android/native_window_jni.h>
|
#include <android/native_window_jni.h>
|
||||||
#include "PlayerEngine.h"
|
#include "PlayerEngine.h"
|
||||||
|
|
||||||
// 기존 월페이퍼 코드 어딘가에 있는 전역 JavaVM 포인터를 가져다 씁니다.
|
|
||||||
extern JavaVM* g_vm;
|
extern JavaVM* g_vm;
|
||||||
|
|
||||||
// C++ 객체를 포인터로 변환하는 헬퍼 함수
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
T* toPlayerNative(jlong handle) {
|
T* toPlayerNative(jlong handle) { return reinterpret_cast<T*>(handle); }
|
||||||
return reinterpret_cast<T*>(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
|
|
||||||
JNIEXPORT jlong JNICALL
|
JNIEXPORT jlong JNICALL
|
||||||
Java_bums_lunatic_launcher_player_NativePlayer_nativeInit(JNIEnv *env, jobject thiz) {
|
Java_bums_lunatic_launcher_player_NativePlayer_nativeInit(JNIEnv *env, jobject thiz) {
|
||||||
// 엔진 생성 시 JavaVM과 Kotlin 콜백 객체(thiz)를 전달합니다.
|
|
||||||
PlayerEngine* engine = new PlayerEngine(g_vm, thiz);
|
PlayerEngine* engine = new PlayerEngine(g_vm, thiz);
|
||||||
return reinterpret_cast<jlong>(engine);
|
return reinterpret_cast<jlong>(engine);
|
||||||
}
|
}
|
||||||
@ -35,35 +27,49 @@ Java_bums_lunatic_launcher_player_NativePlayer_nativeSetSpeed(JNIEnv *env, jobje
|
|||||||
if (engine) engine->setSpeed(speed);
|
if (engine) engine->setSpeed(speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jdouble JNICALL
|
||||||
|
Java_bums_lunatic_launcher_player_NativePlayer_nativeGetCurrentPosition(JNIEnv *env, jobject thiz, jlong handle) {
|
||||||
|
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
||||||
|
return engine ? engine->getCurrentPosition() : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_bums_lunatic_launcher_player_NativePlayer_nativeGetSubtitleTracks(JNIEnv *env, jobject thiz, jlong handle) {
|
||||||
|
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
||||||
|
std::string tracks = engine ? engine->getSubtitleTracks() : "";
|
||||||
|
return env->NewStringUTF(tracks.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
JNIEXPORT void JNICALL
|
JNIEXPORT void JNICALL
|
||||||
Java_bums_lunatic_launcher_player_NativePlayer_nativeSetDataSource(JNIEnv *env, jobject thiz, jlong handle, jint videoFd, jstring jSubPath) {
|
Java_bums_lunatic_launcher_player_NativePlayer_nativeSetSubtitleTrack(JNIEnv *env, jobject thiz, jlong handle, jint index) {
|
||||||
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
||||||
if (engine) {
|
if (engine) engine->setSubtitleTrack(index);
|
||||||
const char* subPath = env->GetStringUTFChars(jSubPath, nullptr);
|
}
|
||||||
|
|
||||||
// 엔진으로 FD 번호를 전달
|
JNIEXPORT void JNICALL
|
||||||
engine->setDataSource(videoFd, subPath);
|
Java_bums_lunatic_launcher_player_NativePlayer_nativeSetDataSource(JNIEnv *env, jobject thiz, jlong handle, jint videoFd, jint jSubFd) {
|
||||||
|
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
||||||
|
if (engine) engine->setDataSource(videoFd, jSubFd);
|
||||||
|
}
|
||||||
|
|
||||||
env->ReleaseStringUTFChars(jSubPath, subPath);
|
// 💡 추가된 비동기 준비
|
||||||
}
|
JNIEXPORT void JNICALL
|
||||||
|
Java_bums_lunatic_launcher_player_NativePlayer_nativePrepareAsync(JNIEnv *env, jobject thiz, jlong handle) {
|
||||||
|
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
||||||
|
if (engine) engine->prepareAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
JNIEXPORT void JNICALL
|
JNIEXPORT void JNICALL
|
||||||
Java_bums_lunatic_launcher_player_NativePlayer_nativePause(JNIEnv *env, jobject thiz, jlong handle) {
|
Java_bums_lunatic_launcher_player_NativePlayer_nativePause(JNIEnv *env, jobject thiz, jlong handle) {
|
||||||
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
||||||
if (engine) {
|
if (engine) engine->pause();
|
||||||
engine->pause(); // 💡 세미콜론 추가
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
JNIEXPORT void JNICALL
|
JNIEXPORT void JNICALL
|
||||||
Java_bums_lunatic_launcher_player_NativePlayer_nativePlay(JNIEnv *env, jobject thiz, jlong handle, jobject surface) {
|
Java_bums_lunatic_launcher_player_NativePlayer_nativePlay(JNIEnv *env, jobject thiz, jlong handle, jobject surface) {
|
||||||
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
PlayerEngine* engine = toPlayerNative<PlayerEngine>(handle);
|
||||||
if (engine && surface) {
|
if (engine && surface) {
|
||||||
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
|
ANativeWindow* window = ANativeWindow_fromSurface(env, surface);
|
||||||
// RGBA 포맷으로 버퍼 설정
|
|
||||||
ANativeWindow_setBuffersGeometry(window, 0, 0, WINDOW_FORMAT_RGBX_8888);
|
ANativeWindow_setBuffersGeometry(window, 0, 0, WINDOW_FORMAT_RGBX_8888);
|
||||||
engine->play(window);
|
engine->play(window);
|
||||||
ANativeWindow_release(window);
|
ANativeWindow_release(window);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import android.content.Intent
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@ -381,6 +382,45 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyToDownloadFolder(filesToCopy: List<File>) {
|
||||||
|
if (filesToCopy.isEmpty()) return
|
||||||
|
|
||||||
|
// 1. 기기의 공용 Download 폴더 경로 가져오기
|
||||||
|
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
|
||||||
|
// 다운로드 폴더 안에 'Lunatic'이라는 앱 전용 폴더를 만들어 깔끔하게 모아둡니다.
|
||||||
|
val targetDir = File(downloadDir, "Lunatic_Downloads")
|
||||||
|
if (!targetDir.exists()) targetDir.mkdirs()
|
||||||
|
|
||||||
|
Toast.makeText(context, "다운로드 폴더로 복사를 시작합니다...", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
// 2. 용량이 클 수 있으므로 백그라운드 스레드에서 복사 진행
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
var copyCount = 0
|
||||||
|
filesToCopy.forEach { file ->
|
||||||
|
try {
|
||||||
|
val destFile = File(targetDir, file.name)
|
||||||
|
if (file.isDirectory) {
|
||||||
|
// 폴더일 경우 내부 파일까지 통째로 복사
|
||||||
|
file.copyRecursively(destFile, overwrite = true)
|
||||||
|
} else {
|
||||||
|
// 단일 파일 복사
|
||||||
|
file.copyTo(destFile, overwrite = true)
|
||||||
|
}
|
||||||
|
copyCount++
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 복사 완료 후 UI 스레드에서 결과 알림
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(requireContext(), "${copyCount}개 항목 복사 완료\n(Download/Lunatic_Downloads)", Toast.LENGTH_LONG).show()
|
||||||
|
toggleSelectionMode(false) // 선택 모드 해제
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 💡 선택한 파일 이동 다이얼로그
|
// 💡 선택한 파일 이동 다이얼로그
|
||||||
private fun showMoveDialog() {
|
private fun showMoveDialog() {
|
||||||
if (selectedFiles.isEmpty()) return
|
if (selectedFiles.isEmpty()) return
|
||||||
@ -477,6 +517,18 @@ class CompletedFilesFragment : Fragment() {
|
|||||||
view.findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
|
view.findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
|
||||||
view.findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
|
view.findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
|
||||||
view.findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
|
view.findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
|
||||||
|
view.findViewById<View>(R.id.btnCopySelected)?.setOnClickListener {
|
||||||
|
if (selectedFiles.isEmpty()) return@setOnClickListener
|
||||||
|
|
||||||
|
android.app.AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle("다운로드 폴더로 내보내기")
|
||||||
|
.setMessage("선택한 ${selectedFiles.size}개 항목을 기기의 다운로드 폴더(Lunatic_Downloads)로 복사하시겠습니까?")
|
||||||
|
.setPositiveButton("복사") { _, _ ->
|
||||||
|
copyToDownloadFolder(selectedFiles.toList())
|
||||||
|
}
|
||||||
|
.setNegativeButton("취소", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
view.findViewById<View>(R.id.btnDeleteSelected)?.setOnClickListener {
|
view.findViewById<View>(R.id.btnDeleteSelected)?.setOnClickListener {
|
||||||
if (selectedFiles.isEmpty()) return@setOnClickListener
|
if (selectedFiles.isEmpty()) return@setOnClickListener
|
||||||
|
|
||||||
|
|||||||
@ -766,14 +766,28 @@ open class GeckoWeb @JvmOverloads constructor(
|
|||||||
|
|
||||||
// Dialog Helpers
|
// Dialog Helpers
|
||||||
private fun showNewSessionDialog(uri: String) {
|
private fun showNewSessionDialog(uri: String) {
|
||||||
AlertDialog.Builder(context)
|
if (uri.startsWith("magnet:?")) {
|
||||||
.setTitle("Move To\n$uri")
|
val intent = Intent(context, TorrentService::class.java).apply {
|
||||||
.setPositiveButton("브라우저로 이동") { _, _ ->
|
putExtra("EXTRA_MAGNET_URI", uri)
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply { flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP })
|
|
||||||
}
|
}
|
||||||
.setNeutralButton("페이지 이동") { _, _ -> loadUrl(uri) }
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
.show()
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AlertDialog.Builder(context)
|
||||||
|
.setTitle("Move To\n$uri")
|
||||||
|
.setPositiveButton("브라우저로 이동") { _, _ ->
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
|
||||||
|
flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.setNeutralButton("페이지 이동") { _, _ -> loadUrl(uri) }
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showContextMenu(pageUrl: String, mediaUrl: String) {
|
private fun showContextMenu(pageUrl: String, mediaUrl: String) {
|
||||||
|
|||||||
@ -54,6 +54,8 @@ import bums.lunatic.launcher.home.tokiz.TokiFragment
|
|||||||
import bums.lunatic.launcher.utils.Blog
|
import bums.lunatic.launcher.utils.Blog
|
||||||
import bums.lunatic.launcher.utils.beforeDay
|
import bums.lunatic.launcher.utils.beforeDay
|
||||||
import bums.lunatic.launcher.workers.WorkersDb
|
import bums.lunatic.launcher.workers.WorkersDb
|
||||||
|
import com.google.android.gms.wearable.MessageClient
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import com.yausername.ffmpeg.FFmpeg
|
import com.yausername.ffmpeg.FFmpeg
|
||||||
import com.yausername.youtubedl_android.YoutubeDL
|
import com.yausername.youtubedl_android.YoutubeDL
|
||||||
@ -121,6 +123,64 @@ open class NeoRssActivity : CommonActivity() {
|
|||||||
var lastAction = MotionEvent.ACTION_HOVER_EXIT
|
var lastAction = MotionEvent.ACTION_HOVER_EXIT
|
||||||
|
|
||||||
|
|
||||||
|
private val messageListener = MessageClient.OnMessageReceivedListener { messageEvent ->
|
||||||
|
val currentFragment = targetFragment ?: supportFragmentManager.fragments.find { it.isVisible }
|
||||||
|
if (currentFragment is RemoteGestureFragment && currentFragment.isRemoteEnabled) {
|
||||||
|
// when(keyType) {
|
||||||
|
// "UP_ARROW"->{currentFragment.onRemoteUp(isDouble)}
|
||||||
|
// "DOWN_ARROW"->{currentFragment.onRemoteDown(isDouble)}
|
||||||
|
//
|
||||||
|
// "LEFT_ARROW"->{currentFragment.onRemoteLeft(isDouble)}
|
||||||
|
// "CENTER_SINGLE_TAP" -> { currentFragment.onRemoteCenterClick() }
|
||||||
|
// "CENTER_DOUBLE_TAP" -> { currentFragment.onRemoteCenterDoubleClick() }
|
||||||
|
// }
|
||||||
|
// when(currentFragment) {
|
||||||
|
// is RssHome ->{
|
||||||
|
// if (currentFragment.binding.layoutRssSummary.root.isVisible) {
|
||||||
|
// currentFragment.openGecko(rssData = currentFragment.randomOrNull())
|
||||||
|
// } else {
|
||||||
|
// currentFragment.doNextPage()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// is TokiFragment -> {
|
||||||
|
// currentFragment.back()
|
||||||
|
// }
|
||||||
|
// is CompletedFilesFragment -> {
|
||||||
|
// currentFragment.backPress()
|
||||||
|
// }
|
||||||
|
// else -> {
|
||||||
|
//// showContents(R.id.close)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
when (messageEvent.path) {
|
||||||
|
"/gesture/next" -> {
|
||||||
|
when(currentFragment) {
|
||||||
|
is RssHome ->{
|
||||||
|
if (currentFragment.binding.layoutRssSummary.root.isVisible) {
|
||||||
|
currentFragment.openGecko(rssData = currentFragment.randomOrNull())
|
||||||
|
} else {
|
||||||
|
currentFragment.doNextPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is TokiFragment -> {
|
||||||
|
currentFragment.back()
|
||||||
|
}
|
||||||
|
is CompletedFilesFragment -> {
|
||||||
|
currentFragment.backPress()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// showContents(R.id.close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/gesture/prev" -> {
|
||||||
|
currentFragment.onRemoteLeft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
override fun onKeyLongPress(keyCode: Int, ev: KeyEvent?): Boolean {
|
override fun onKeyLongPress(keyCode: Int, ev: KeyEvent?): Boolean {
|
||||||
Blog.LOGE("keyEvent >>>>> ${ev?.device?.name}:${keyCode}: onKeyLongPress >>> ${ev} ")
|
Blog.LOGE("keyEvent >>>>> ${ev?.device?.name}:${keyCode}: onKeyLongPress >>> ${ev} ")
|
||||||
return super.onKeyLongPress(keyCode, ev)
|
return super.onKeyLongPress(keyCode, ev)
|
||||||
@ -494,7 +554,7 @@ open class NeoRssActivity : CommonActivity() {
|
|||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
Wearable.getMessageClient(this).removeListener(messageListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -545,7 +605,10 @@ open class NeoRssActivity : CommonActivity() {
|
|||||||
R.id.btn_x -> TokiFragment.newInstanceX()
|
R.id.btn_x -> TokiFragment.newInstanceX()
|
||||||
R.id.btn_i -> TokiFragment.newInstanceI()
|
R.id.btn_i -> TokiFragment.newInstanceI()
|
||||||
R.id.btn_btsearch -> TokiFragment.newInstanceMagnet()
|
R.id.btn_btsearch -> TokiFragment.newInstanceMagnet()
|
||||||
// R.id.btn_img4 -> TokiFragment.newInstanceTumblr()
|
R.id.btn_img4 -> {
|
||||||
|
startActivity(Intent(this@NeoRssActivity, TranslatorActivity::class.java))
|
||||||
|
targetFragment
|
||||||
|
}
|
||||||
R.id.btn_img3 -> TokiFragment.newInstanceTumblr()
|
R.id.btn_img3 -> TokiFragment.newInstanceTumblr()
|
||||||
// R.id.btn_img2 -> TokiFragment.newInstancePixiv()
|
// R.id.btn_img2 -> TokiFragment.newInstancePixiv()
|
||||||
// R.id.btn_img1 -> TokiFragment.newInstanceArtStation()
|
// R.id.btn_img1 -> TokiFragment.newInstanceArtStation()
|
||||||
@ -605,6 +668,7 @@ open class NeoRssActivity : CommonActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
Blog.LOGE("LauncherActivity onResume")
|
Blog.LOGE("LauncherActivity onResume")
|
||||||
|
Wearable.getMessageClient(this).addListener(messageListener)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openSearch() {
|
private fun openSearch() {
|
||||||
|
|||||||
@ -739,7 +739,7 @@ internal class RssHome : RemoteGestureFragment() , KeyEventHandler {
|
|||||||
// 일반 WebView라면: webView.onPause() 및 webView.pauseTimers()
|
// 일반 WebView라면: webView.onPause() 및 webView.pauseTimers()
|
||||||
} else {
|
} else {
|
||||||
// 💡 다시 나타날 때: 다시 시작
|
// 💡 다시 나타날 때: 다시 시작
|
||||||
// binding.geckoWeb?.onResume()
|
binding.lunaticBrowser.geckoWeb?.onResume()
|
||||||
// 일반 WebView라면: webView.onResume() 및 webView.resumeTimers()
|
// 일반 WebView라면: webView.onResume() 및 webView.resumeTimers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,174 @@
|
|||||||
|
package bums.lunatic.launcher.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.*
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import bums.lunatic.launcher.R
|
||||||
|
import com.google.mlkit.common.model.DownloadConditions
|
||||||
|
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||||
|
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||||
|
import com.google.mlkit.nl.translate.Translation
|
||||||
|
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class TranslatorActivity : AppCompatActivity(), TextToSpeech.OnInitListener {
|
||||||
|
|
||||||
|
private lateinit var etInputText: EditText
|
||||||
|
private lateinit var spinnerTargetLang: Spinner
|
||||||
|
private lateinit var btnTranslate: Button
|
||||||
|
private lateinit var tvOutputText: TextView
|
||||||
|
private lateinit var layoutProgress: LinearLayout
|
||||||
|
private lateinit var tvSwap: TextView
|
||||||
|
private lateinit var tvTtsInput: TextView
|
||||||
|
private lateinit var tvTtsOutput: TextView
|
||||||
|
private lateinit var sbTtsSpeed: SeekBar
|
||||||
|
private lateinit var tvInputLangStatus: TextView
|
||||||
|
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private var detectedSourceLangCode: String? = null
|
||||||
|
private var ttsSpeed: Float = 1.0f
|
||||||
|
|
||||||
|
private val targetLanguages = listOf(
|
||||||
|
Pair("한국어 🇰🇷", TranslateLanguage.KOREAN),
|
||||||
|
Pair("영어 🇺🇸", TranslateLanguage.ENGLISH),
|
||||||
|
Pair("일본어 🇯🇵", TranslateLanguage.JAPANESE),
|
||||||
|
Pair("중국어 🇨🇳", TranslateLanguage.CHINESE),
|
||||||
|
Pair("스페인어 🇪🇸", TranslateLanguage.SPANISH),
|
||||||
|
Pair("프랑스어 🇫🇷", TranslateLanguage.FRENCH)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_translator)
|
||||||
|
|
||||||
|
tts = TextToSpeech(this, this)
|
||||||
|
initViews()
|
||||||
|
setupListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initViews() {
|
||||||
|
etInputText = findViewById(R.id.etInputText)
|
||||||
|
spinnerTargetLang = findViewById(R.id.spinnerTargetLang)
|
||||||
|
btnTranslate = findViewById(R.id.btnTranslate)
|
||||||
|
tvOutputText = findViewById(R.id.tvOutputText)
|
||||||
|
layoutProgress = findViewById(R.id.layoutProgress)
|
||||||
|
tvSwap = findViewById(R.id.tvSwap)
|
||||||
|
tvTtsInput = findViewById(R.id.tvTtsInput)
|
||||||
|
tvTtsOutput = findViewById(R.id.tvTtsOutput)
|
||||||
|
sbTtsSpeed = findViewById(R.id.sbTtsSpeed)
|
||||||
|
tvInputLangStatus = findViewById(R.id.tvInputLangStatus)
|
||||||
|
|
||||||
|
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, targetLanguages.map { it.first })
|
||||||
|
spinnerTargetLang.adapter = adapter
|
||||||
|
spinnerTargetLang.setSelection(1) // 기본 영어
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
btnTranslate.setOnClickListener {
|
||||||
|
val text = etInputText.text.toString().trim()
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
val targetCode = targetLanguages[spinnerTargetLang.selectedItemPosition].second
|
||||||
|
detectAndTranslate(text, targetCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tvSwap.setOnClickListener {
|
||||||
|
val input = etInputText.text.toString()
|
||||||
|
val output = tvOutputText.text.toString()
|
||||||
|
if (output.isNotEmpty() && !output.startsWith("번역")) {
|
||||||
|
etInputText.setText(output)
|
||||||
|
tvOutputText.text = input
|
||||||
|
detectedSourceLangCode?.let { source ->
|
||||||
|
val idx = targetLanguages.indexOfFirst { it.second == source }
|
||||||
|
if (idx != -1) spinnerTargetLang.setSelection(idx)
|
||||||
|
}
|
||||||
|
detectedSourceLangCode = null // 스왑 후에는 다시 감지 필요
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sbTtsSpeed.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||||
|
// 0~200 범위를 0.1~2.0배속으로 변환 (progress 100이 1.0배속)
|
||||||
|
ttsSpeed = progress / 100f
|
||||||
|
if (ttsSpeed < 0.1f) ttsSpeed = 0.1f
|
||||||
|
tts?.setSpeechRate(ttsSpeed)
|
||||||
|
}
|
||||||
|
override fun onStartTrackingTouch(p0: SeekBar?) {}
|
||||||
|
override fun onStopTrackingTouch(p0: SeekBar?) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
tvTtsInput.setOnClickListener { playTts(etInputText.text.toString(), detectedSourceLangCode) }
|
||||||
|
tvTtsOutput.setOnClickListener {
|
||||||
|
val code = targetLanguages[spinnerTargetLang.selectedItemPosition].second
|
||||||
|
playTts(tvOutputText.text.toString(), code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectAndTranslate(text: String, targetLangCode: String) {
|
||||||
|
tvOutputText.text = "언어 감지 중..."
|
||||||
|
btnTranslate.isEnabled = false
|
||||||
|
|
||||||
|
LanguageIdentification.getClient().identifyLanguage(text)
|
||||||
|
.addOnSuccessListener { languageCode ->
|
||||||
|
detectedSourceLangCode = TranslateLanguage.fromLanguageTag(languageCode)
|
||||||
|
if (detectedSourceLangCode != null && languageCode != "und") {
|
||||||
|
tvInputLangStatus.text = "원본: ${languageCode.uppercase()}"
|
||||||
|
tvTtsInput.visibility = View.VISIBLE
|
||||||
|
performTranslation(text, detectedSourceLangCode!!, targetLangCode)
|
||||||
|
} else {
|
||||||
|
tvOutputText.text = "언어를 감지할 수 없습니다."
|
||||||
|
btnTranslate.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performTranslation(text: String, source: String, target: String) {
|
||||||
|
if (source == target) {
|
||||||
|
tvOutputText.text = text
|
||||||
|
btnTranslate.isEnabled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutProgress.visibility = View.VISIBLE
|
||||||
|
val options = TranslatorOptions.Builder().setSourceLanguage(source).setTargetLanguage(target).build()
|
||||||
|
val translator = Translation.getClient(options)
|
||||||
|
|
||||||
|
translator.downloadModelIfNeeded(DownloadConditions.Builder().build())
|
||||||
|
.addOnSuccessListener {
|
||||||
|
layoutProgress.visibility = View.GONE
|
||||||
|
translator.translate(text)
|
||||||
|
.addOnSuccessListener { translated ->
|
||||||
|
tvOutputText.text = translated
|
||||||
|
tvTtsOutput.visibility = View.VISIBLE
|
||||||
|
btnTranslate.isEnabled = true
|
||||||
|
}
|
||||||
|
.addOnFailureListener { btnTranslate.isEnabled = true }
|
||||||
|
}
|
||||||
|
.addOnFailureListener {
|
||||||
|
layoutProgress.visibility = View.GONE
|
||||||
|
btnTranslate.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playTts(text: String, langCode: String?) {
|
||||||
|
if (text.isEmpty() || langCode == null) return
|
||||||
|
val locale = when (langCode) {
|
||||||
|
TranslateLanguage.KOREAN -> Locale.KOREAN
|
||||||
|
TranslateLanguage.ENGLISH -> Locale.ENGLISH
|
||||||
|
TranslateLanguage.JAPANESE -> Locale.JAPANESE
|
||||||
|
TranslateLanguage.CHINESE -> Locale.CHINESE
|
||||||
|
else -> Locale.getDefault()
|
||||||
|
}
|
||||||
|
tts?.apply {
|
||||||
|
language = locale
|
||||||
|
setSpeechRate(ttsSpeed) // 현재 SeekBar에 설정된 속도 적용
|
||||||
|
speak(text, TextToSpeech.QUEUE_FLUSH, null, "ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) Log.d("TTS", "Ready") }
|
||||||
|
override fun onDestroy() { tts?.stop(); tts?.shutdown(); super.onDestroy() }
|
||||||
|
}
|
||||||
@ -7,17 +7,22 @@ class NativePlayer {
|
|||||||
private var subtitleCallback: ((String) -> Unit)? = null
|
private var subtitleCallback: ((String) -> Unit)? = null
|
||||||
private var videoSizeCallback: ((Int, Int) -> Unit)? = null
|
private var videoSizeCallback: ((Int, Int) -> Unit)? = null
|
||||||
|
|
||||||
|
// 💡 JNI로부터 받을 콜백 리스너 설정
|
||||||
|
var onPreparedListener: (() -> Unit)? = null
|
||||||
|
var onErrorListener: ((Int, String) -> Unit)? = null
|
||||||
|
|
||||||
fun initialize(): Boolean {
|
fun initialize(): Boolean {
|
||||||
nativeHandle = nativeInit()
|
nativeHandle = nativeInit()
|
||||||
return nativeHandle != 0L
|
return nativeHandle != 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDataSource(videoFd: Int, subPath: String) = nativeSetDataSource(nativeHandle, videoFd, subPath)
|
fun setDataSource(videoFd: Int, subFd: Int) = nativeSetDataSource(nativeHandle, videoFd, subFd)
|
||||||
|
fun prepareAsync() { if (nativeHandle != 0L) nativePrepareAsync(nativeHandle) }
|
||||||
fun play(surface: Surface) = nativePlay(nativeHandle, surface)
|
fun play(surface: Surface) = nativePlay(nativeHandle, surface)
|
||||||
|
fun pause() { if (nativeHandle != 0L) nativePause(nativeHandle) }
|
||||||
fun stop() = nativeStop(nativeHandle)
|
fun stop() = nativeStop(nativeHandle)
|
||||||
fun seekBy(sec: Double) = nativeSeekBy(nativeHandle, sec)
|
fun seekBy(sec: Double) = nativeSeekBy(nativeHandle, sec)
|
||||||
fun setSpeed(speed: Float) = nativeSetSpeed(nativeHandle, speed)
|
fun setSpeed(speed: Float) = nativeSetSpeed(nativeHandle, speed)
|
||||||
fun pause() {nativePause(nativeHandle)}
|
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
private fun onSubtitleTextDecoded(text: String) {
|
private fun onSubtitleTextDecoded(text: String) {
|
||||||
@ -29,6 +34,18 @@ class NativePlayer {
|
|||||||
videoSizeCallback?.invoke(w, h)
|
videoSizeCallback?.invoke(w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💡 C++에서 준비 완료 시 호출
|
||||||
|
@Suppress("unused")
|
||||||
|
private fun onNativePrepared() {
|
||||||
|
onPreparedListener?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 C++에서 오류 발생 시 호출
|
||||||
|
@Suppress("unused")
|
||||||
|
private fun onNativeError(errorCode: Int, errorMessage: String) {
|
||||||
|
onErrorListener?.invoke(errorCode, errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
fun setSubtitleCallback(cb: (String) -> Unit) { subtitleCallback = cb }
|
fun setSubtitleCallback(cb: (String) -> Unit) { subtitleCallback = cb }
|
||||||
fun setVideoSizeCallback(cb: (Int, Int) -> Unit) { videoSizeCallback = cb }
|
fun setVideoSizeCallback(cb: (Int, Int) -> Unit) { videoSizeCallback = cb }
|
||||||
|
|
||||||
@ -38,14 +55,42 @@ class NativePlayer {
|
|||||||
nativeHandle = 0L
|
nativeHandle = 0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private external fun nativePause(h: Long)
|
|
||||||
|
data class SubtitleTrack(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val isExternal: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getInternalSubtitleTracks(): List<SubtitleTrack> {
|
||||||
|
if (nativeHandle == 0L) return emptyList()
|
||||||
|
val rawStr = nativeGetSubtitleTracks(nativeHandle) ?: return emptyList()
|
||||||
|
return rawStr.split(",").filter { it.isNotBlank() }.mapNotNull {
|
||||||
|
val parts = it.split(":")
|
||||||
|
if (parts.size >= 2) SubtitleTrack(parts[0], parts[1], false) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInternalSubtitleTrack(streamIndex: Int) {
|
||||||
|
if (nativeHandle != 0L) nativeSetSubtitleTrack(nativeHandle, streamIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentPosition(): Double {
|
||||||
|
return if (nativeHandle != 0L) nativeGetCurrentPosition(nativeHandle) else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
private external fun nativeInit(): Long
|
private external fun nativeInit(): Long
|
||||||
private external fun nativeSetDataSource(h: Long, fd: Int, sub: String)
|
private external fun nativeSetDataSource(h: Long, videoFd: Int, subFd: Int)
|
||||||
|
private external fun nativePrepareAsync(h: Long)
|
||||||
private external fun nativePlay(h: Long, s: Surface)
|
private external fun nativePlay(h: Long, s: Surface)
|
||||||
|
private external fun nativePause(h: Long)
|
||||||
private external fun nativeStop(h: Long)
|
private external fun nativeStop(h: Long)
|
||||||
private external fun nativeDestroy(h: Long)
|
private external fun nativeDestroy(h: Long)
|
||||||
private external fun nativeSeekBy(h: Long, s: Double)
|
private external fun nativeSeekBy(h: Long, s: Double)
|
||||||
private external fun nativeSetSpeed(h: Long, sp: Float)
|
private external fun nativeSetSpeed(h: Long, sp: Float)
|
||||||
|
private external fun nativeGetCurrentPosition(h: Long): Double
|
||||||
|
private external fun nativeGetSubtitleTracks(h: Long): String
|
||||||
|
private external fun nativeSetSubtitleTrack(h: Long, index: Int)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init { System.loadLibrary("native_renderer") }
|
init { System.loadLibrary("native_renderer") }
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package bums.lunatic.launcher.player
|
package bums.lunatic.launcher.player
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@ -10,12 +12,28 @@ import android.view.*
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import bums.lunatic.launcher.R
|
import bums.lunatic.launcher.R
|
||||||
|
import bums.lunatic.launcher.player.NativePlayer.SubtitleTrack
|
||||||
|
import bums.lunatic.launcher.utils.Blog
|
||||||
|
import com.google.mlkit.nl.languageid.LanguageIdentification
|
||||||
|
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||||
|
import com.google.mlkit.nl.translate.Translation
|
||||||
|
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
import java.nio.charset.CodingErrorAction
|
||||||
|
|
||||||
class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
||||||
|
data class SubtitleBlock(
|
||||||
|
val startSec: Double,
|
||||||
|
val endSec: Double,
|
||||||
|
val text: String,
|
||||||
|
var translatedText: String? = null // 💡 번역본이 저장될 공간
|
||||||
|
)
|
||||||
|
|
||||||
private lateinit var videoTextureView: TextureView
|
private lateinit var videoTextureView: TextureView
|
||||||
private lateinit var subtitleView: TextView
|
private lateinit var subtitleView: TextView
|
||||||
@ -33,48 +51,74 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
|
|
||||||
|
|
||||||
|
private var externalSubtitles = listOf<SubtitleBlock>()
|
||||||
|
private var subtitleSyncJob: Job? = null
|
||||||
|
private val allSubtitleTracks = mutableListOf<SubtitleTrack>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
}
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// 1. 데이터 확인
|
|
||||||
videoPath = intent.getStringExtra("VIDEO_PATH") ?: ""
|
videoPath = intent.getStringExtra("VIDEO_PATH") ?: ""
|
||||||
if (videoPath.isEmpty()) { finish(); return }
|
if (videoPath.isEmpty()) { finish(); return }
|
||||||
subtitlePath = findSubtitleFile(videoPath)
|
|
||||||
|
|
||||||
// 2. UI 동적 생성 및 구성
|
subtitlePath = findSubtitleFile(videoPath)
|
||||||
|
if (subtitlePath.isNotEmpty()) {
|
||||||
|
externalSubtitles = parseSrt(File(subtitlePath))
|
||||||
|
detectLanguageAndTranslate()
|
||||||
|
}
|
||||||
|
|
||||||
setupUI()
|
setupUI()
|
||||||
|
|
||||||
// 3. 네이티브 플레이어 초기화
|
|
||||||
nativePlayer = NativePlayer().apply {
|
nativePlayer = NativePlayer().apply {
|
||||||
initialize()
|
initialize()
|
||||||
// 영상 해상도에 따른 화면 크기 조절 콜백
|
|
||||||
setVideoSizeCallback { width, height ->
|
setVideoSizeCallback { width, height ->
|
||||||
videoWidth = width
|
videoWidth = width
|
||||||
videoHeight = height
|
videoHeight = height
|
||||||
adjustVideoAspectRatio(width, height)
|
adjustVideoAspectRatio(width, height)
|
||||||
}
|
}
|
||||||
// 자막 출력 콜백
|
|
||||||
setSubtitleCallback { text ->
|
setSubtitleCallback { text ->
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
subtitleView.text = cleanSubtitleText(text)
|
subtitleView.text = cleanSubtitleText(text)
|
||||||
subtitleView.visibility = if (text.isEmpty()) View.INVISIBLE else View.VISIBLE
|
subtitleView.visibility = if (text.isEmpty()) View.INVISIBLE else View.VISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💡 C++에서 준비 완료 시 호출
|
||||||
|
onPreparedListener = {
|
||||||
|
runOnUiThread {
|
||||||
|
loadAvailableSubtitles()
|
||||||
|
if (allSubtitleTracks.size > 1) {
|
||||||
|
showSubtitleSelectionDialog()
|
||||||
|
} else {
|
||||||
|
// 자막 없으면 자동 시작
|
||||||
|
play(Surface(videoTextureView.surfaceTexture!!))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 C++에서 오류 발생 시 호출
|
||||||
|
onErrorListener = { errorCode, errorMessage ->
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(this@PlayerActivity, "오류 [$errorCode]: $errorMessage", Toast.LENGTH_LONG).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 제스처 설정
|
|
||||||
setupGestures()
|
setupGestures()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupUI() {
|
private fun setupUI() {
|
||||||
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
|
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
|
||||||
|
|
||||||
// 비디오 뷰
|
videoTextureView = TextureView(this).apply { surfaceTextureListener = this@PlayerActivity }
|
||||||
videoTextureView = TextureView(this).apply {
|
|
||||||
surfaceTextureListener = this@PlayerActivity
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자막 뷰
|
|
||||||
subtitleView = TextView(this).apply {
|
subtitleView = TextView(this).apply {
|
||||||
setTextColor(Color.WHITE)
|
setTextColor(Color.WHITE)
|
||||||
textSize = 22f
|
textSize = 22f
|
||||||
@ -83,7 +127,6 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
setPadding(40, 0, 40, 180)
|
setPadding(40, 0, 40, 180)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 제스처 감지용 투명 레이어 (삼등분)
|
|
||||||
val gestureLayer = android.widget.LinearLayout(this).apply {
|
val gestureLayer = android.widget.LinearLayout(this).apply {
|
||||||
orientation = android.widget.LinearLayout.HORIZONTAL
|
orientation = android.widget.LinearLayout.HORIZONTAL
|
||||||
weightSum = 3f
|
weightSum = 3f
|
||||||
@ -97,10 +140,8 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
gestureLayer.addView(centerZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
|
gestureLayer.addView(centerZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
|
||||||
gestureLayer.addView(rightZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
|
gestureLayer.addView(rightZone, android.widget.LinearLayout.LayoutParams(0, -1, 1f))
|
||||||
|
|
||||||
// 컨트롤 버튼 (좌측 하단 회전, 우측 하단 숨기기)
|
|
||||||
val controls = FrameLayout(this)
|
|
||||||
btnRotate = ImageButton(this).apply {
|
btnRotate = ImageButton(this).apply {
|
||||||
setImageResource(android.R.drawable.ic_menu_rotate) // 기본 리소스 사용
|
setImageResource(android.R.drawable.ic_menu_rotate)
|
||||||
setBackgroundColor(Color.TRANSPARENT)
|
setBackgroundColor(Color.TRANSPARENT)
|
||||||
setOnClickListener { toggleOrientation() }
|
setOnClickListener { toggleOrientation() }
|
||||||
}
|
}
|
||||||
@ -113,8 +154,6 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
root.addView(videoTextureView, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
|
root.addView(videoTextureView, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
|
||||||
root.addView(subtitleView)
|
root.addView(subtitleView)
|
||||||
root.addView(gestureLayer)
|
root.addView(gestureLayer)
|
||||||
|
|
||||||
// 버튼 배치
|
|
||||||
root.addView(btnRotate, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.START).apply { setMargins(30,0,0,30) })
|
root.addView(btnRotate, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.START).apply { setMargins(30,0,0,30) })
|
||||||
root.addView(btnHideVideo, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.END).apply { setMargins(0,0,30,30) })
|
root.addView(btnHideVideo, FrameLayout.LayoutParams(150, 150, Gravity.BOTTOM or Gravity.END).apply { setMargins(0,0,30,30) })
|
||||||
|
|
||||||
@ -122,37 +161,89 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
hideSystemUI()
|
hideSystemUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
|
||||||
|
val videoFile = File(videoPath)
|
||||||
|
if (videoFile.exists()) {
|
||||||
|
val videoPfd = ParcelFileDescriptor.open(videoFile, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
|
val videoFd = videoPfd.detachFd()
|
||||||
|
|
||||||
|
var subFd = -1
|
||||||
|
if (subtitlePath.isNotEmpty()) {
|
||||||
|
val subFile = File(subtitlePath)
|
||||||
|
if (subFile.exists()) {
|
||||||
|
val subPfd = ParcelFileDescriptor.open(subFile, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
|
subFd = subPfd.detachFd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C++로 DataSource를 넘기고 비동기 Prepare 명령!
|
||||||
|
nativePlayer?.setDataSource(videoFd, subFd)
|
||||||
|
nativePlayer?.prepareAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAvailableSubtitles() {
|
||||||
|
allSubtitleTracks.clear()
|
||||||
|
allSubtitleTracks.add(SubtitleTrack("-1", "자막 끄기", false))
|
||||||
|
|
||||||
|
val internalTracks = nativePlayer?.getInternalSubtitleTracks() ?: emptyList()
|
||||||
|
allSubtitleTracks.addAll(internalTracks)
|
||||||
|
|
||||||
|
if (externalSubtitles.isNotEmpty()) {
|
||||||
|
allSubtitleTracks.add(SubtitleTrack("EXTERNAL", "외부 자막 (SRT)", true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSubtitleSelectionDialog() {
|
||||||
|
val trackNames = allSubtitleTracks.map { it.name }.toTypedArray()
|
||||||
|
AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert)
|
||||||
|
.setTitle("자막 선택")
|
||||||
|
.setCancelable(false)
|
||||||
|
.setItems(trackNames) { _, which ->
|
||||||
|
selectSubtitleTrack(allSubtitleTracks[which])
|
||||||
|
nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectSubtitleTrack(track: SubtitleTrack) {
|
||||||
|
if (track.isExternal) {
|
||||||
|
nativePlayer?.setInternalSubtitleTrack(-1)
|
||||||
|
startSubtitleSyncLoop()
|
||||||
|
} else {
|
||||||
|
subtitleSyncJob?.cancel()
|
||||||
|
subtitleView.visibility = View.INVISIBLE
|
||||||
|
nativePlayer?.setInternalSubtitleTrack(track.id.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupGestures() {
|
private fun setupGestures() {
|
||||||
val gestureLayer = (videoTextureView.parent as FrameLayout).getChildAt(2) as android.widget.LinearLayout
|
val gestureLayer = (videoTextureView.parent as FrameLayout).getChildAt(2) as android.widget.LinearLayout
|
||||||
val left = gestureLayer.getChildAt(0)
|
val left = gestureLayer.getChildAt(0)
|
||||||
val center = gestureLayer.getChildAt(1)
|
val center = gestureLayer.getChildAt(1)
|
||||||
val right = gestureLayer.getChildAt(2)
|
val right = gestureLayer.getChildAt(2)
|
||||||
|
|
||||||
// 1. 가운데: 재생/일시정지
|
|
||||||
center.setOnClickListener {
|
center.setOnClickListener {
|
||||||
isPlaying = !isPlaying
|
isPlaying = !isPlaying
|
||||||
if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture))
|
if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
|
||||||
else nativePlayer?.pause()
|
else nativePlayer?.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 오른쪽: 롱프레스 4배속
|
|
||||||
val rightDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
val rightDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
||||||
override fun onLongPress(e: MotionEvent) { nativePlayer?.setSpeed(4.0f) }
|
override fun onLongPress(e: MotionEvent) { nativePlayer?.setSpeed(4.0f) }
|
||||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||||
nativePlayer?.seekBy(20.0) // 탭하면 10초 앞으로
|
nativePlayer?.seekBy(20.0)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
right.setOnTouchListener { v, event ->
|
right.setOnTouchListener { _, event ->
|
||||||
rightDetector.onTouchEvent(event)
|
rightDetector.onTouchEvent(event)
|
||||||
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
||||||
nativePlayer?.setSpeed(1.0f)
|
nativePlayer?.setSpeed(1.0f)
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 왼쪽: 롱프레스 주기적 뒤로가기
|
|
||||||
val leftDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
val leftDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
||||||
override fun onLongPress(e: MotionEvent) {
|
override fun onLongPress(e: MotionEvent) {
|
||||||
leftLongPressJob = CoroutineScope(Dispatchers.Main).launch {
|
leftLongPressJob = CoroutineScope(Dispatchers.Main).launch {
|
||||||
@ -163,11 +254,11 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||||
nativePlayer?.seekBy(-10.0) // 탭하면 10초 뒤로
|
nativePlayer?.seekBy(-10.0)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
left.setOnTouchListener { v, event ->
|
left.setOnTouchListener { _, event ->
|
||||||
leftDetector.onTouchEvent(event)
|
leftDetector.onTouchEvent(event)
|
||||||
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
||||||
leftLongPressJob?.cancel()
|
leftLongPressJob?.cancel()
|
||||||
@ -178,16 +269,11 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
|
|
||||||
// 시스템 UI(상태바 등)를 다시 숨깁니다.
|
|
||||||
hideSystemUI()
|
hideSystemUI()
|
||||||
|
if (videoWidth > 0 && videoHeight > 0) adjustVideoAspectRatio(videoWidth, videoHeight)
|
||||||
// 저장해둔 영상 해상도가 있다면 현재 바뀐 화면 크기에 맞춰 다시 정렬합니다.
|
|
||||||
if (videoWidth > 0 && videoHeight > 0) {
|
|
||||||
adjustVideoAspectRatio(videoWidth, videoHeight)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💡 화면 채움 (Fill/Crop)
|
||||||
private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) {
|
private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
// 1. 현재 기기의 실제 가용 화면 크기를 가져옵니다.
|
// 1. 현재 기기의 실제 가용 화면 크기를 가져옵니다.
|
||||||
@ -242,27 +328,238 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
|
|||||||
|
|
||||||
private fun cleanSubtitleText(text: String): String = text.replace(Regex("\\{.*?\\}"), "")
|
private fun cleanSubtitleText(text: String): String = text.replace(Regex("\\{.*?\\}"), "")
|
||||||
|
|
||||||
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
|
var lastSubTitle : String = ""
|
||||||
val file = File(videoPath)
|
private fun startSubtitleSyncLoop() {
|
||||||
if (file.exists()) {
|
subtitleSyncJob?.cancel()
|
||||||
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
subtitleSyncJob = CoroutineScope(Dispatchers.Main).launch {
|
||||||
nativePlayer?.setDataSource(pfd.detachFd(), subtitlePath)
|
while (isActive) {
|
||||||
nativePlayer?.play(Surface(st))
|
if (isPlaying) {
|
||||||
|
val currentSec = nativePlayer?.getCurrentPosition() ?: 0.0
|
||||||
|
val currentSub = externalSubtitles.find { currentSec in it.startSec..it.endSec }
|
||||||
|
|
||||||
|
if (currentSub != null) {
|
||||||
|
// 💡 번역본이 존재하면 [번역본] + [줄바꿈] + [원본] 형태로 보여주거나, 번역본만 보여줍니다.
|
||||||
|
val displayText = if (currentSub.translatedText != null) {
|
||||||
|
"${currentSub.translatedText}\n${cleanSubtitleText(currentSub.text)}"
|
||||||
|
// (원본이 보기 싫다면 그냥 currentSub.translatedText 만 넣으셔도 됩니다!)
|
||||||
|
} else {
|
||||||
|
cleanSubtitleText(currentSub.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayText != lastSubTitle) {
|
||||||
|
lastSubTitle = displayText
|
||||||
|
subtitleView.text = displayText
|
||||||
|
subtitleView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
subtitleView.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun readTextWithEncoding(file: File): String {
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
if (bytes.isEmpty()) return ""
|
||||||
|
|
||||||
|
// 1. 꼬리표(BOM) 100% 확정 검사
|
||||||
|
if (bytes.size >= 3 && bytes[0] == 0xEF.toByte() && bytes[1] == 0xBB.toByte() && bytes[2] == 0xBF.toByte()) {
|
||||||
|
return String(bytes, 3, bytes.size - 3, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
if (bytes.size >= 2 && bytes[0] == 0xFF.toByte() && bytes[1] == 0xFE.toByte()) {
|
||||||
|
return String(bytes, 2, bytes.size - 2, Charset.forName("UTF-16LE"))
|
||||||
|
}
|
||||||
|
if (bytes.size >= 2 && bytes[0] == 0xFE.toByte() && bytes[1] == 0xFF.toByte()) {
|
||||||
|
return String(bytes, 2, bytes.size - 2, Charset.forName("UTF-16BE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. UTF-16 검사 (Null 바이트 비율)
|
||||||
|
val nullCount = bytes.count { it == 0.toByte() }
|
||||||
|
if (nullCount > bytes.size / 4) {
|
||||||
|
return String(bytes, Charset.forName("UTF-16LE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 3. UTF-8 강제 우선권 부여 (중국어 블랙홀 방지)
|
||||||
|
try {
|
||||||
|
val decoder = Charsets.UTF_8.newDecoder()
|
||||||
|
decoder.onMalformedInput(CodingErrorAction.REPLACE)
|
||||||
|
decoder.onUnmappableCharacter(CodingErrorAction.REPLACE)
|
||||||
|
decoder.replaceWith("\uFFFD")
|
||||||
|
|
||||||
|
val utf8Text = decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||||
|
val utf8Errors = utf8Text.count { it == '\uFFFD' }
|
||||||
|
|
||||||
|
// 에러가 5% 미만이라면 사실상 UTF-8 파일이 부분 손상된 것으로 간주하고 확정!
|
||||||
|
if (utf8Errors < utf8Text.length / 20) {
|
||||||
|
return utf8Text
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
|
||||||
|
// 💡 4. 한국어/일본어 전용 채점 (GB18030 같은 블랙홀은 리스트에서 배제)
|
||||||
|
val charsets = listOf("CP949", "Shift_JIS", "EUC-JP")
|
||||||
|
var bestText = String(bytes, Charsets.UTF_8) // 최후의 보루는 UTF-8
|
||||||
|
var minErrors = Int.MAX_VALUE
|
||||||
|
|
||||||
|
for (charsetName in charsets) {
|
||||||
|
try {
|
||||||
|
val decoder = Charset.forName(charsetName).newDecoder()
|
||||||
|
decoder.onMalformedInput(CodingErrorAction.REPLACE)
|
||||||
|
decoder.onUnmappableCharacter(CodingErrorAction.REPLACE)
|
||||||
|
decoder.replaceWith("\uFFFD")
|
||||||
|
|
||||||
|
val text = decoder.decode(ByteBuffer.wrap(bytes)).toString()
|
||||||
|
val errorCount = text.count { it == '\uFFFD' }
|
||||||
|
|
||||||
|
// 에러가 가장 적은 인코딩 채택
|
||||||
|
if (errorCount < minErrors) {
|
||||||
|
minErrors = errorCount
|
||||||
|
bestText = text
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestText
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSrt(file: File): List<SubtitleBlock> {
|
||||||
|
val result = mutableListOf<SubtitleBlock>()
|
||||||
|
if (!file.exists()) return result
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 💡 기존의 file.readText() 대신 우리가 만든 스마트 함수를 사용합니다.
|
||||||
|
val content = readTextWithEncoding(file)
|
||||||
|
|
||||||
|
// --- 아래는 기존과 완전히 동일 ---
|
||||||
|
val blocks = content.split("\n\n", "\r\n\r\n")
|
||||||
|
|
||||||
|
for (block in blocks) {
|
||||||
|
val lines = block.lines().filter { it.isNotBlank() }
|
||||||
|
if (lines.size >= 3) {
|
||||||
|
val timeLine = lines[1]
|
||||||
|
val text = lines.drop(2).joinToString("\n")
|
||||||
|
|
||||||
|
val times = timeLine.split(" --> ")
|
||||||
|
if (times.size == 2) {
|
||||||
|
result.add(SubtitleBlock(parseSrtTime(times[0]), parseSrtTime(times[1]), text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("PlayerActivity", "Subtitle parsing error", e)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSrtTime(timeStr: String): Double {
|
||||||
|
val cleanStr = timeStr.trim().replace(",", ".")
|
||||||
|
val parts = cleanStr.split(":")
|
||||||
|
if (parts.size == 3) {
|
||||||
|
val h = parts[0].toDoubleOrNull() ?: 0.0
|
||||||
|
val m = parts[1].toDoubleOrNull() ?: 0.0
|
||||||
|
val s = parts[2].toDoubleOrNull() ?: 0.0
|
||||||
|
return (h * 3600) + (m * 60) + s
|
||||||
|
}
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
private fun hideSystemUI() {
|
private fun hideSystemUI() {
|
||||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)
|
window.setDecorFitsSystemWindows(false)
|
||||||
|
window.insetsController?.let {
|
||||||
|
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
|
||||||
|
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_FULLSCREEN)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {}
|
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {}
|
||||||
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { nativePlayer?.stop(); return true }
|
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { nativePlayer?.stop(); return true }
|
||||||
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
|
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
|
||||||
|
|
||||||
|
private fun detectLanguageAndTranslate() {
|
||||||
|
if (externalSubtitles.isEmpty()) return
|
||||||
|
|
||||||
|
// 정확도를 위해 자막 앞부분의 텍스트를 적당히 뭉쳐서 샘플로 만듭니다.
|
||||||
|
val sampleText = externalSubtitles.take(10).joinToString(" ") { it.text }
|
||||||
|
|
||||||
|
val languageIdentifier = LanguageIdentification.getClient()
|
||||||
|
languageIdentifier.identifyLanguage(sampleText)
|
||||||
|
.addOnSuccessListener { languageCode ->
|
||||||
|
if (languageCode == "und") {
|
||||||
|
Blog.LOGE("언어 감지 실패 (알 수 없는 언어)")
|
||||||
|
} else {
|
||||||
|
Blog.LOGE("💡 감지된 언어 코드: $languageCode")
|
||||||
|
|
||||||
|
// 만약 이미 한국어(ko)라면 번역할 필요가 없으므로 종료
|
||||||
|
if (languageCode == "ko") return@addOnSuccessListener
|
||||||
|
|
||||||
|
// 감지된 언어 코드를 번역기가 이해할 수 있는 코드로 변환
|
||||||
|
val sourceLang = TranslateLanguage.fromLanguageTag(languageCode)
|
||||||
|
if (sourceLang != null) {
|
||||||
|
startTranslationProcess(sourceLang)
|
||||||
|
} else {
|
||||||
|
Blog.LOGE("번역을 지원하지 않는 언어입니다: $languageCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener { e ->
|
||||||
|
Blog.LOGE("언어 감지 에러: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 2. 감지된 언어를 바탕으로 한국어로 번역하는 함수
|
||||||
|
private fun startTranslationProcess(sourceLang: String) {
|
||||||
|
val options = TranslatorOptions.Builder()
|
||||||
|
.setSourceLanguage(sourceLang)
|
||||||
|
.setTargetLanguage(TranslateLanguage.KOREAN) // 타겟은 무조건 한국어!
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val translator = Translation.getClient(options)
|
||||||
|
|
||||||
|
Toast.makeText(this, "번역 모델 확인 중...", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
// 해당 언어 모델이 폰에 없으면 다운로드 (최초 1회, 약 30MB)
|
||||||
|
translator.downloadModelIfNeeded()
|
||||||
|
.addOnSuccessListener {
|
||||||
|
Toast.makeText(this, "자막 번역을 시작합니다!", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
// 💡 3. 수백~수천 줄의 자막을 백그라운드에서 한방에 번역 (UI 멈춤 방지)
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
for (block in externalSubtitles) {
|
||||||
|
try {
|
||||||
|
// ML Kit의 번역은 원래 비동기(Task)지만, Tasks.await를 쓰면 동기적으로 쫙 뽑아낼 수 있습니다.
|
||||||
|
val translated = com.google.android.gms.tasks.Tasks.await(translator.translate(block.text))
|
||||||
|
block.translatedText = translated
|
||||||
|
} catch (e: Exception) {
|
||||||
|
block.translatedText = block.text // 에러나면 원본 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@PlayerActivity, "자막 번역 완료!", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener {
|
||||||
|
Toast.makeText(this, "번역 모델 다운로드 실패", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
nativePlayer?.destroy()
|
nativePlayer?.destroy()
|
||||||
leftLongPressJob?.cancel()
|
leftLongPressJob?.cancel()
|
||||||
|
subtitleSyncJob?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
163
app/src/main/res/layout/activity_translator.xml
Normal file
163
app/src/main/res/layout/activity_translator.xml
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:background="#F5F5F5">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="🌐 ML Kit Translator"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="16dp"/>
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp">
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvInputLangStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="원본 (자동 감지)"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_centerVertical="true"/>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTtsInput"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="volume_up"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:fontFamily="@font/material_symbols"
|
||||||
|
android:textColor="#2196F3"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/etInputText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@android:color/white"
|
||||||
|
android:gravity="top|start"
|
||||||
|
android:hint="번역할 내용을 입력하세요."
|
||||||
|
android:padding="12dp"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:elevation="2dp"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:background="#E0E0E0"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSwap"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="swap_vert"
|
||||||
|
android:textSize="28sp"
|
||||||
|
android:fontFamily="@font/material_symbols"
|
||||||
|
android:textColor="#444444"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"/>
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinnerTargetLang"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_toEndOf="@id/tvSwap"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_marginStart="8dp"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnTranslate"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:text="번역"/>
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginTop="4dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="TTS 속도"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginEnd="8dp"/>
|
||||||
|
<SeekBar
|
||||||
|
android:id="@+id/sbTtsSpeed"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:max="200"
|
||||||
|
android:progress="100"/> </LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="4dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="번역 결과"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_centerVertical="true"/>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTtsOutput"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="volume_up"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:fontFamily="@font/material_symbols"
|
||||||
|
android:textColor="#2196F3"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:visibility="gone"/>
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOutputText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@android:color/white"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:elevation="2dp"
|
||||||
|
android:hint="번역 결과가 표시됩니다."/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutProgress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_marginTop="8dp">
|
||||||
|
<ProgressBar android:layout_width="20dp" android:layout_height="20dp" android:layout_marginEnd="8dp" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" text="모델 다운로드 중..." textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@ -147,6 +147,15 @@
|
|||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="12dp"/>
|
android:padding="12dp"/>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnCopySelected"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="외부폴더로 복사"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:gravity="center"
|
||||||
|
android:padding="12dp"/>
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btnMoveSelected"
|
android:id="@+id/btnMoveSelected"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@ -120,12 +120,11 @@
|
|||||||
style="@style/CommonFabStyle"
|
style="@style/CommonFabStyle"
|
||||||
android:id="@+id/btn_img3"
|
android:id="@+id/btn_img3"
|
||||||
/>
|
/>
|
||||||
<!-- <bums.lunatic.launcher.view.FloatingActionButton-->
|
<bums.lunatic.launcher.view.FloatingActionButton
|
||||||
<!-- app:fab_label="⛏️"-->
|
app:fab_label="🌐"
|
||||||
<!-- android:visibility="gone"-->
|
style="@style/CommonFabStyle"
|
||||||
<!-- style="@style/CommonFabStyle"-->
|
android:id="@+id/btn_img4"
|
||||||
<!-- android:id="@+id/btn_img4"-->
|
/>
|
||||||
<!-- />-->
|
|
||||||
|
|
||||||
<bums.lunatic.launcher.view.FloatingActionButton
|
<bums.lunatic.launcher.view.FloatingActionButton
|
||||||
app:fab_label="🌍"
|
app:fab_label="🌍"
|
||||||
|
|||||||
@ -17,6 +17,15 @@
|
|||||||
<item name="scrimBackground">@color/almost_transparent</item>
|
<item name="scrimBackground">@color/almost_transparent</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="Theme.Player" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar">
|
||||||
|
<!-- Background -->
|
||||||
|
<item name="android:windowShowWallpaper">false</item>
|
||||||
|
<item name="android:windowIsTranslucent">false</item>
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<style name="roundedImageView" parent="">
|
<style name="roundedImageView" parent="">
|
||||||
<item name="cornerFamily">rounded</item>
|
<item name="cornerFamily">rounded</item>
|
||||||
<item name="cornerSize">10dp</item>
|
<item name="cornerSize">10dp</item>
|
||||||
|
|||||||
@ -28,6 +28,8 @@
|
|||||||
android:name="android.support.wearable.complications.UPDATE_PERIOD_SECONDS"
|
android:name="android.support.wearable.complications.UPDATE_PERIOD_SECONDS"
|
||||||
android:value="0" />
|
android:value="0" />
|
||||||
</service>
|
</service>
|
||||||
|
<receiver android:name=".tile.TileActionReceiver" android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".tile.MainTileService"
|
android:name=".tile.MainTileService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
package bums.lunatic.launcher.tile
|
package bums.lunatic.launcher.tile
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.wear.protolayout.ActionBuilders
|
||||||
import androidx.wear.protolayout.ColorBuilders.argb
|
import androidx.wear.protolayout.ColorBuilders.argb
|
||||||
|
import androidx.wear.protolayout.DimensionBuilders.dp
|
||||||
import androidx.wear.protolayout.LayoutElementBuilders
|
import androidx.wear.protolayout.LayoutElementBuilders
|
||||||
|
import androidx.wear.protolayout.ModifiersBuilders
|
||||||
import androidx.wear.protolayout.ResourceBuilders
|
import androidx.wear.protolayout.ResourceBuilders
|
||||||
import androidx.wear.protolayout.TimelineBuilders
|
import androidx.wear.protolayout.TimelineBuilders
|
||||||
import androidx.wear.protolayout.material.Colors
|
import androidx.wear.protolayout.material.Colors
|
||||||
@ -14,6 +20,7 @@ import androidx.wear.tiles.TileBuilders
|
|||||||
import androidx.wear.tiles.tooling.preview.Preview
|
import androidx.wear.tiles.tooling.preview.Preview
|
||||||
import androidx.wear.tiles.tooling.preview.TilePreviewData
|
import androidx.wear.tiles.tooling.preview.TilePreviewData
|
||||||
import androidx.wear.tooling.preview.devices.WearDevices
|
import androidx.wear.tooling.preview.devices.WearDevices
|
||||||
|
import com.google.android.gms.wearable.Wearable
|
||||||
import com.google.android.horologist.annotations.ExperimentalHorologistApi
|
import com.google.android.horologist.annotations.ExperimentalHorologistApi
|
||||||
import com.google.android.horologist.tiles.SuspendingTileService
|
import com.google.android.horologist.tiles.SuspendingTileService
|
||||||
|
|
||||||
@ -64,22 +71,77 @@ private fun tile(
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tileLayout(
|
private fun tileLayout(requestParams: RequestBuilders.TileRequest, context: Context): LayoutElementBuilders.LayoutElement {
|
||||||
requestParams: RequestBuilders.TileRequest,
|
|
||||||
context: Context,
|
fun createNavButton(label: String, path: String,x : Float, y :Float): LayoutElementBuilders.LayoutElement {
|
||||||
): LayoutElementBuilders.LayoutElement {
|
// 리시버를 실행하기 위한 PendingIntent 생성
|
||||||
|
val intent = Intent(context, TileActionReceiver::class.java).apply {
|
||||||
|
putExtra("path", path)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context, path.hashCode(), intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
return LayoutElementBuilders.Box.Builder()
|
||||||
|
.setModifiers(ModifiersBuilders.Modifiers.Builder()
|
||||||
|
.setClickable(ModifiersBuilders.Clickable.Builder()
|
||||||
|
.setOnClick(ActionBuilders.LaunchAction.Builder()
|
||||||
|
.setAndroidActivity(ActionBuilders.AndroidActivity.Builder()
|
||||||
|
.setPackageName(context.packageName)
|
||||||
|
.setClassName(TileActionReceiver::class.java.name) // 리시버 호출
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
.setTransformation(ModifiersBuilders.Transformation.Builder()
|
||||||
|
// 방향에 따라 x, y 좌표 조절 (상: 0,-45 / 하: 0,45 / 좌: -45,0 / 우: 45,0)
|
||||||
|
.setTranslationX(dp(x))
|
||||||
|
.setTranslationY(dp(y))
|
||||||
|
.build())
|
||||||
|
.setBackground(ModifiersBuilders.Background.Builder()
|
||||||
|
.setColor(argb(0xFF303030.toInt()))
|
||||||
|
.setCorner(ModifiersBuilders.Corner.Builder().setRadius(dp(15f)).build())
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
.setWidth(dp(56f)).setHeight(dp(56f))
|
||||||
|
.addContent(Text.Builder(context, label).build())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
return PrimaryLayout.Builder(requestParams.deviceConfiguration)
|
return PrimaryLayout.Builder(requestParams.deviceConfiguration)
|
||||||
.setResponsiveContentInsetEnabled(true)
|
|
||||||
.setContent(
|
.setContent(
|
||||||
Text.Builder(context, "Hello World!")
|
LayoutElementBuilders.Box.Builder()
|
||||||
.setColor(argb(Colors.DEFAULT.onSurface))
|
// 상 (UP): X는 그대로, Y만 위로(-45dp)
|
||||||
.setTypography(Typography.TYPOGRAPHY_CAPTION1)
|
.addContent(createNavButton("UP", "/gesture/up", 0f, -50f))
|
||||||
|
|
||||||
|
// 하 (DOWN): X는 그대로, Y만 아래로(+50dp)
|
||||||
|
.addContent(createNavButton("DOWN", "/gesture/down", 0f, 50f))
|
||||||
|
|
||||||
|
// 좌 (LEFT): X를 왼쪽으로(-50dp), Y는 그대로
|
||||||
|
.addContent(createNavButton("LEFT", "/gesture/left", -50f, 0f))
|
||||||
|
|
||||||
|
// 우 (RIGHT): X를 오른쪽으로(+50dp), Y는 그대로
|
||||||
|
.addContent(createNavButton("RIGHT", "/gesture/right", 50f, 0f))
|
||||||
.build()
|
.build()
|
||||||
).build()
|
).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Preview(device = WearDevices.SMALL_ROUND)
|
@Preview(device = WearDevices.SMALL_ROUND)
|
||||||
@Preview(device = WearDevices.LARGE_ROUND)
|
@Preview(device = WearDevices.LARGE_ROUND)
|
||||||
fun tilePreview(context: Context) = TilePreviewData(::resources) {
|
fun tilePreview(context: Context) = TilePreviewData(::resources) {
|
||||||
tile(it, context)
|
tile(it, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
class TileActionReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val path = intent.getStringExtra("path") ?: return
|
||||||
|
|
||||||
|
// 폰(런처)으로 메시지 전송
|
||||||
|
Wearable.getNodeClient(context).connectedNodes.addOnSuccessListener { nodes ->
|
||||||
|
for (node in nodes) {
|
||||||
|
Wearable.getMessageClient(context).sendMessage(node.id, path, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user