From 162d2b756319238cb806e6561d6997af0fde5bd0 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Fri, 10 Apr 2026 15:31:07 +0900 Subject: [PATCH] ... --- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 17 +- app/src/main/cpp/PlayerEngine.cpp | 220 +++++----- app/src/main/cpp/PlayerEngine.h | 26 +- app/src/main/cpp/native_player.cpp | 48 ++- .../launcher/home/CompletedFilesFragment.kt | 52 +++ .../bums/lunatic/launcher/home/GeckoWeb.kt | 28 +- .../lunatic/launcher/home/NeoRssActivity.kt | 68 +++- .../bums/lunatic/launcher/home/RssHome.kt | 2 +- .../launcher/home/TranslatorActivity.kt | 174 ++++++++ .../lunatic/launcher/player/NativePlayer.kt | 53 ++- .../lunatic/launcher/player/PlayerActivity.kt | 381 ++++++++++++++++-- .../main/res/layout/activity_translator.xml | 163 ++++++++ .../res/layout/fragment_completed_files.xml | 9 + app/src/main/res/layout/rss_activity.xml | 11 +- app/src/main/res/values/themes.xml | 9 + lun_launcher/src/main/AndroidManifest.xml | 2 + .../lunatic/launcher/tile/MainTileService.kt | 78 +++- 18 files changed, 1151 insertions(+), 195 deletions(-) create mode 100644 app/src/main/kotlin/bums/lunatic/launcher/home/TranslatorActivity.kt create mode 100644 app/src/main/res/layout/activity_translator.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2dd6e62..158fa105 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -229,6 +229,11 @@ dependencies { // (선택) CameraX 확장 라이브러리 (보케, HDR 등) 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 { // ⚠️ 이 버전을 프로젝트 루트의 build.gradle.kts에 정의된 kotlinVersion 값과 정확히 일치시키세요. val targetKotlinVersion = "2.0.20" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bbb8c5be..a6d8490d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,6 +62,14 @@ + + + + + + + + - + - + \ No newline at end of file diff --git a/app/src/main/cpp/PlayerEngine.cpp b/app/src/main/cpp/PlayerEngine.cpp index 38524e10..aa92defa 100644 --- a/app/src/main/cpp/PlayerEngine.cpp +++ b/app/src/main/cpp/PlayerEngine.cpp @@ -10,7 +10,6 @@ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, 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) { int fd = (int)(intptr_t)opaque; 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; } -// [2. 생성자 및 초기화] PlayerEngine::PlayerEngine(JavaVM* vm, jobject listenerObj) : jvm_(vm) { JNIEnv* env; jvm_->GetEnv((void**)&env, JNI_VERSION_1_6); listenerObj_ = env->NewGlobalRef(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); if (env->ExceptionCheck()) { env->ExceptionClear(); @@ -48,73 +46,44 @@ PlayerEngine::PlayerEngine(JavaVM* vm, jobject listenerObj) : jvm_(vm) { return id; }; - subtitleMethodId_ = getMethod("onSubtitleTextDecoded", "(Ljava/lang/String;)V"); - videoSizeMethodId_ = getMethod("onVideoSizeChanged", "(II)V"); + subtitleMethodId_ = safeGetMethod("onSubtitleTextDecoded", "(Ljava/lang/String;)V"); + videoSizeMethodId_ = safeGetMethod("onVideoSizeChanged", "(II)V"); + preparedMethodId_ = safeGetMethod("onNativePrepared", "()V"); + errorMethodId_ = safeGetMethod("onNativeError", "(ILjava/lang/String;)V"); } PlayerEngine::~PlayerEngine() { stop(); + if (prepareThread_.joinable()) prepareThread_.join(); // 준비 스레드 대기 if (listenerObj_) { - JNIEnv* env; - jvm_->GetEnv((void**)&env, JNI_VERSION_1_6); + JNIEnv* env; jvm_->GetEnv((void**)&env, JNI_VERSION_1_6); env->DeleteGlobalRef(listenerObj_); } } -void PlayerEngine::setDataSource(int videoFd, const std::string& subtitlePath) { +void PlayerEngine::setDataSource(int videoFd, int subtitleFd) { videoFd_ = videoFd; - subtitlePath_ = subtitlePath; + subtitleFd_ = subtitleFd; } -void PlayerEngine::play(ANativeWindow* window) { - if (isPlaying_) { - isPaused_ = false; // 💡 이미 재생 중이면 일시정지만 해제 - return; - } - window_ = window; - ANativeWindow_acquire(window_); - isPlaying_ = true; - renderThread_ = std::thread(&PlayerEngine::renderLoop, this); +void PlayerEngine::prepareAsync() { + if (isPrepared_ || prepareThread_.joinable()) return; + prepareThread_ = std::thread(&PlayerEngine::prepareInternal, this); } -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; +void PlayerEngine::prepareInternal() { 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(); -} + jvm_->AttachCurrentThread(&env, nullptr); -void PlayerEngine::pause() { - - isPaused_ = true; -} - -// [3. 메인 렌더링 루프] -void PlayerEngine::renderLoop() { - LOGI("Player render loop started (AAudio Mode)"); + auto sendError = [&](int code, const char* msg) { + LOGE("Prepare Error [%d]: %s", code, msg); + if (errorMethodId_) { + jstring jMsg = env->NewStringUTF(msg); + env->CallVoidMethod(listenerObj_, errorMethodId_, code, jMsg); + env->DeleteLocalRef(jMsg); + } + jvm_->DetachCurrentThread(); + }; int avio_buffer_size = 32768; 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_->pb = avio_ctx; - if (avformat_open_input(&fmt_ctx_, nullptr, nullptr, nullptr) < 0) return; - avformat_find_stream_info(fmt_ctx_, nullptr); + if (avformat_open_input(&fmt_ctx_, nullptr, nullptr, nullptr) < 0) { + 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++) { auto type = fmt_ctx_->streams[i]->codecpar->codec_type; 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_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) { auto par = fmt_ctx_->streams[video_stream_idx_]->codecpar; auto codec = avcodec_find_decoder(par->codec_id); video_codec_ctx_ = avcodec_alloc_context3(codec); avcodec_parameters_to_context(video_codec_ctx_, par); - if (avcodec_open2(video_codec_ctx_, codec, nullptr) == 0) { - JNIEnv* env; jvm_->AttachCurrentThread(&env, nullptr); - if (videoSizeMethodId_) env->CallVoidMethod(listenerObj_, videoSizeMethodId_, video_codec_ctx_->width, video_codec_ctx_->height); - jvm_->DetachCurrentThread(); + if (avcodec_open2(video_codec_ctx_, codec, nullptr) == 0 && videoSizeMethodId_) { + env->CallVoidMethod(listenerObj_, videoSizeMethodId_, video_codec_ctx_->width, video_codec_ctx_->height); } } - // 💡 [AAudio 초기화 복구] if (audio_stream_idx_ >= 0) { auto par = fmt_ctx_->streams[audio_stream_idx_]->codecpar; auto codec = avcodec_find_decoder(par->codec_id); @@ -162,33 +139,99 @@ void PlayerEngine::renderLoop() { AAudioStreamBuilder_setChannelCount(builder, 2); AAudioStreamBuilder_setSampleRate(builder, 48000); 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); } } + 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(); AVPacket* pkt = av_packet_alloc(); int last_win_w = 0, last_win_h = 0; while (isPlaying_) { if (isPaused_) { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - continue; // 💡 일시정지 중이면 루프를 돌며 대기만 함 + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + continue; } - // [Seek 요청] + if (seekReq_) { 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 (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; } float currentSpeed = playbackSpeed_.load(); if (av_read_frame(fmt_ctx_, pkt) < 0) break; - // [비디오 처리] if (pkt->stream_index == video_stream_idx_) { avcodec_send_packet(video_codec_ctx_, pkt); 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) { std::this_thread::sleep_for(std::chrono::milliseconds((int)(16/currentSpeed))); } } - // [오디오 처리] else if (pkt->stream_index == audio_stream_idx_) { - if (currentSpeed == 1.0f) { // 정배속일 때만 재생 + if (currentSpeed == 1.0f) { avcodec_send_packet(audio_codec_ctx_, pkt); while (avcodec_receive_frame(audio_codec_ctx_, frame) == 0) { if (swr_ctx_ && audio_stream_) { - // 1. 출력될 샘플 수 계산 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); - - // 3. 변환 수행 (실제로 변환된 정확한 샘플 수를 반환받음) - int converted_samples = swr_convert(swr_ctx_, &out_buf, out_samples, - (const uint8_t**)frame->data, frame->nb_samples); - + int converted_samples = swr_convert(swr_ctx_, &out_buf, out_samples, (const uint8_t**)frame->data, frame->nb_samples); if (converted_samples > 0) { - // 💡 [핵심 수정] out_size(바이트) 대신 converted_samples(샘플 수)를 전달해야 함 - // 세 번째 인자는 'samples' 단위여야 합니다. AAudioStream_write(audio_stream_, out_buf, converted_samples, 1000000000); } free(out_buf); } } } - } else if (pkt->stream_index == sub_stream_idx_) { - AVSubtitle sub; - int got_sub = 0; - // 자막 패킷 디코딩 + } + else if (pkt->stream_index == current_sub_stream_idx_) { + AVSubtitle sub; int got_sub = 0; avcodec_decode_subtitle2(sub_codec_ctx_, &sub, &got_sub, pkt); if (got_sub) { 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); - } else if (sub.rects[i]->type == SUBTITLE_ASS && sub.rects[i]->ass) { - // ASS 자막의 경우 마크업 태그가 포함될 수 있음 - sendSubtitleToKotlin(sub.rects[i]->ass); - } + if (sub.rects[i]->type == SUBTITLE_TEXT && sub.rects[i]->text) sendSubtitleToKotlin(sub.rects[i]->text); + else if (sub.rects[i]->type == SUBTITLE_ASS && sub.rects[i]->ass) sendSubtitleToKotlin(sub.rects[i]->ass); } - avsubtitle_free(&sub); // 메모리 해제 필수 + avsubtitle_free(&sub); } } av_packet_unref(pkt); } - // 자원 해제 if (audio_stream_) { AAudioStream_requestStop(audio_stream_); AAudioStream_close(audio_stream_); audio_stream_ = nullptr; } av_frame_free(&frame); av_packet_free(&pkt); @@ -270,4 +295,7 @@ void PlayerEngine::renderLoop() { if (audio_codec_ctx_) avcodec_free_context(&audio_codec_ctx_); if (fmt_ctx_) avformat_close_input(&fmt_ctx_); LOGI("Player loop finished gracefully."); -} \ No newline at end of file +} + +std::string PlayerEngine::getSubtitleTracks() { return subtitle_tracks_info_; } +void PlayerEngine::setSubtitleTrack(int streamIndex) { current_sub_stream_idx_ = streamIndex; } \ No newline at end of file diff --git a/app/src/main/cpp/PlayerEngine.h b/app/src/main/cpp/PlayerEngine.h index 66d774d5..c386a922 100644 --- a/app/src/main/cpp/PlayerEngine.h +++ b/app/src/main/cpp/PlayerEngine.h @@ -4,7 +4,7 @@ #include #include #include -#include // 💡 AAudio 추가 +#include extern "C" { #include @@ -20,22 +20,31 @@ public: PlayerEngine(JavaVM* vm, jobject listenerObj); ~PlayerEngine(); - void setDataSource(int videoFd, const std::string& subtitlePath); + void setDataSource(int videoFd, int subtitleFd); + void prepareAsync(); // 💡 비동기 준비 시작 함수 void play(ANativeWindow* window); void pause(); void stop(); void seekBy(double seconds); void setSpeed(float speed); + double getCurrentPosition() const { return currentPosSec_; } + + std::string getSubtitleTracks(); + void setSubtitleTrack(int streamIndex); private: + void prepareInternal(); // 💡 백그라운드 준비 스레드 void renderLoop(); void sendSubtitleToKotlin(const char* text); - int videoFd_ = -1; - std::string subtitlePath_; + int subtitleFd_ = -1; + + std::atomic isPrepared_{false}; std::atomic isPlaying_{false}; - std::atomic isPaused_{false}; // 💡 일시정지 플래그 추가 + std::atomic isPaused_{false}; + + std::thread prepareThread_; std::thread renderThread_; ANativeWindow* window_ = nullptr; @@ -44,6 +53,9 @@ private: std::atomic playbackSpeed_{1.0f}; double currentPosSec_ = 0.0; + int current_sub_stream_idx_ = -1; + std::string subtitle_tracks_info_; + AVFormatContext* fmt_ctx_ = nullptr; AVCodecContext* video_codec_ctx_ = nullptr; AVCodecContext* sub_codec_ctx_ = nullptr; @@ -55,10 +67,12 @@ private: int sub_stream_idx_ = -1; int audio_stream_idx_ = -1; - AAudioStream* audio_stream_ = nullptr; // 💡 AAudio 복구 + AAudioStream* audio_stream_ = nullptr; JavaVM* jvm_ = nullptr; jobject listenerObj_ = nullptr; jmethodID subtitleMethodId_ = nullptr; jmethodID videoSizeMethodId_ = nullptr; + jmethodID preparedMethodId_ = nullptr; // 💡 JNI 콜백 ID 추가 + jmethodID errorMethodId_ = nullptr; // 💡 JNI 에러 콜백 ID 추가 }; \ No newline at end of file diff --git a/app/src/main/cpp/native_player.cpp b/app/src/main/cpp/native_player.cpp index 6c48e246..beb2e3c9 100644 --- a/app/src/main/cpp/native_player.cpp +++ b/app/src/main/cpp/native_player.cpp @@ -1,24 +1,16 @@ -// -// Created by JIBUM HAN on 2026. 4. 9.. -// #include #include #include "PlayerEngine.h" -// 기존 월페이퍼 코드 어딘가에 있는 전역 JavaVM 포인터를 가져다 씁니다. extern JavaVM* g_vm; -// C++ 객체를 포인터로 변환하는 헬퍼 함수 template -T* toPlayerNative(jlong handle) { - return reinterpret_cast(handle); -} +T* toPlayerNative(jlong handle) { return reinterpret_cast(handle); } extern "C" { JNIEXPORT jlong JNICALL Java_bums_lunatic_launcher_player_NativePlayer_nativeInit(JNIEnv *env, jobject thiz) { - // 엔진 생성 시 JavaVM과 Kotlin 콜백 객체(thiz)를 전달합니다. PlayerEngine* engine = new PlayerEngine(g_vm, thiz); return reinterpret_cast(engine); } @@ -35,35 +27,49 @@ Java_bums_lunatic_launcher_player_NativePlayer_nativeSetSpeed(JNIEnv *env, jobje if (engine) engine->setSpeed(speed); } +JNIEXPORT jdouble JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativeGetCurrentPosition(JNIEnv *env, jobject thiz, jlong handle) { + PlayerEngine* engine = toPlayerNative(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(handle); + std::string tracks = engine ? engine->getSubtitleTracks() : ""; + return env->NewStringUTF(tracks.c_str()); +} 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(handle); - if (engine) { - const char* subPath = env->GetStringUTFChars(jSubPath, nullptr); + if (engine) engine->setSubtitleTrack(index); +} - // 엔진으로 FD 번호를 전달 - engine->setDataSource(videoFd, subPath); +JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativeSetDataSource(JNIEnv *env, jobject thiz, jlong handle, jint videoFd, jint jSubFd) { + PlayerEngine* engine = toPlayerNative(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(handle); + if (engine) engine->prepareAsync(); } JNIEXPORT void JNICALL Java_bums_lunatic_launcher_player_NativePlayer_nativePause(JNIEnv *env, jobject thiz, jlong handle) { PlayerEngine* engine = toPlayerNative(handle); - if (engine) { - engine->pause(); // 💡 세미콜론 추가 - } + if (engine) engine->pause(); } - JNIEXPORT void JNICALL Java_bums_lunatic_launcher_player_NativePlayer_nativePlay(JNIEnv *env, jobject thiz, jlong handle, jobject surface) { PlayerEngine* engine = toPlayerNative(handle); if (engine && surface) { ANativeWindow* window = ANativeWindow_fromSurface(env, surface); - // RGBA 포맷으로 버퍼 설정 ANativeWindow_setBuffersGeometry(window, 0, 0, WINDOW_FORMAT_RGBX_8888); engine->play(window); ANativeWindow_release(window); diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt index f0810cbb..f54ac029 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.graphics.Color import android.net.Uri import android.os.Bundle +import android.os.Environment import android.text.Editable import android.text.TextWatcher import android.view.LayoutInflater @@ -381,6 +382,45 @@ class CompletedFilesFragment : Fragment() { .show() } + private fun copyToDownloadFolder(filesToCopy: List) { + 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() { if (selectedFiles.isEmpty()) return @@ -477,6 +517,18 @@ class CompletedFilesFragment : Fragment() { view.findViewById(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) } view.findViewById(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() } view.findViewById(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() } + view.findViewById(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(R.id.btnDeleteSelected)?.setOnClickListener { if (selectedFiles.isEmpty()) return@setOnClickListener diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt index 42129ca6..949c6c8d 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt @@ -766,14 +766,28 @@ open class GeckoWeb @JvmOverloads constructor( // Dialog Helpers private fun showNewSessionDialog(uri: String) { - 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 }) + if (uri.startsWith("magnet:?")) { + val intent = Intent(context, TorrentService::class.java).apply { + putExtra("EXTRA_MAGNET_URI", uri) } - .setNeutralButton("페이지 이동") { _, _ -> loadUrl(uri) } - .setNegativeButton(android.R.string.cancel, null) - .show() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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) { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt index c9aa1bb6..622bb14b 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt @@ -54,6 +54,8 @@ import bums.lunatic.launcher.home.tokiz.TokiFragment import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.utils.beforeDay 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.yausername.ffmpeg.FFmpeg import com.yausername.youtubedl_android.YoutubeDL @@ -121,6 +123,64 @@ open class NeoRssActivity : CommonActivity() { 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 { Blog.LOGE("keyEvent >>>>> ${ev?.device?.name}:${keyCode}: onKeyLongPress >>> ${ev} ") return super.onKeyLongPress(keyCode, ev) @@ -494,7 +554,7 @@ open class NeoRssActivity : CommonActivity() { override fun 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_i -> TokiFragment.newInstanceI() 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_img2 -> TokiFragment.newInstancePixiv() // R.id.btn_img1 -> TokiFragment.newInstanceArtStation() @@ -605,6 +668,7 @@ open class NeoRssActivity : CommonActivity() { override fun onResume() { super.onResume() Blog.LOGE("LauncherActivity onResume") + Wearable.getMessageClient(this).addListener(messageListener) } private fun openSearch() { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt index 8e4fb700..612f2fbe 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/RssHome.kt @@ -739,7 +739,7 @@ internal class RssHome : RemoteGestureFragment() , KeyEventHandler { // 일반 WebView라면: webView.onPause() 및 webView.pauseTimers() } else { // 💡 다시 나타날 때: 다시 시작 -// binding.geckoWeb?.onResume() + binding.lunaticBrowser.geckoWeb?.onResume() // 일반 WebView라면: webView.onResume() 및 webView.resumeTimers() } } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/TranslatorActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/TranslatorActivity.kt new file mode 100644 index 00000000..0e9a9317 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/TranslatorActivity.kt @@ -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() } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/player/NativePlayer.kt b/app/src/main/kotlin/bums/lunatic/launcher/player/NativePlayer.kt index 2c6e0fef..b79388e6 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/player/NativePlayer.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/NativePlayer.kt @@ -7,17 +7,22 @@ class NativePlayer { private var subtitleCallback: ((String) -> Unit)? = null private var videoSizeCallback: ((Int, Int) -> Unit)? = null + // 💡 JNI로부터 받을 콜백 리스너 설정 + var onPreparedListener: (() -> Unit)? = null + var onErrorListener: ((Int, String) -> Unit)? = null + fun initialize(): Boolean { nativeHandle = nativeInit() 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 pause() { if (nativeHandle != 0L) nativePause(nativeHandle) } fun stop() = nativeStop(nativeHandle) fun seekBy(sec: Double) = nativeSeekBy(nativeHandle, sec) fun setSpeed(speed: Float) = nativeSetSpeed(nativeHandle, speed) - fun pause() {nativePause(nativeHandle)} @Suppress("unused") private fun onSubtitleTextDecoded(text: String) { @@ -29,6 +34,18 @@ class NativePlayer { 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 setVideoSizeCallback(cb: (Int, Int) -> Unit) { videoSizeCallback = cb } @@ -38,14 +55,42 @@ class NativePlayer { nativeHandle = 0L } } - private external fun nativePause(h: Long) + + data class SubtitleTrack( + val id: String, + val name: String, + val isExternal: Boolean + ) + + fun getInternalSubtitleTracks(): List { + 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 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 nativePause(h: Long) private external fun nativeStop(h: Long) private external fun nativeDestroy(h: Long) private external fun nativeSeekBy(h: Long, s: Double) 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 { init { System.loadLibrary("native_renderer") } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt index 209860c6..460d66a0 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt @@ -1,8 +1,10 @@ package bums.lunatic.launcher.player +import android.app.AlertDialog import android.content.pm.ActivityInfo import android.graphics.Color import android.graphics.SurfaceTexture +import android.os.Build import android.os.Bundle import android.os.ParcelFileDescriptor import android.util.Log @@ -10,12 +12,28 @@ import android.view.* import android.widget.FrameLayout import android.widget.ImageButton import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity 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 java.io.File +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.nio.charset.CodingErrorAction 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 subtitleView: TextView @@ -33,48 +51,74 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { private var videoWidth: Int = 0 private var videoHeight: Int = 0 + + private var externalSubtitles = listOf() + private var subtitleSyncJob: Job? = null + private val allSubtitleTracks = mutableListOf() + 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) - // 1. 데이터 확인 videoPath = intent.getStringExtra("VIDEO_PATH") ?: "" if (videoPath.isEmpty()) { finish(); return } - subtitlePath = findSubtitleFile(videoPath) - // 2. UI 동적 생성 및 구성 + subtitlePath = findSubtitleFile(videoPath) + if (subtitlePath.isNotEmpty()) { + externalSubtitles = parseSrt(File(subtitlePath)) + detectLanguageAndTranslate() + } + setupUI() - // 3. 네이티브 플레이어 초기화 nativePlayer = NativePlayer().apply { initialize() - // 영상 해상도에 따른 화면 크기 조절 콜백 + setVideoSizeCallback { width, height -> videoWidth = width videoHeight = height adjustVideoAspectRatio(width, height) } - // 자막 출력 콜백 + setSubtitleCallback { text -> runOnUiThread { subtitleView.text = cleanSubtitleText(text) 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() } private fun setupUI() { 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 { setTextColor(Color.WHITE) textSize = 22f @@ -83,7 +127,6 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { setPadding(40, 0, 40, 180) } - // 제스처 감지용 투명 레이어 (삼등분) val gestureLayer = android.widget.LinearLayout(this).apply { orientation = android.widget.LinearLayout.HORIZONTAL weightSum = 3f @@ -97,10 +140,8 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { gestureLayer.addView(centerZone, 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 { - setImageResource(android.R.drawable.ic_menu_rotate) // 기본 리소스 사용 + setImageResource(android.R.drawable.ic_menu_rotate) setBackgroundColor(Color.TRANSPARENT) setOnClickListener { toggleOrientation() } } @@ -113,8 +154,6 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { root.addView(videoTextureView, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER)) root.addView(subtitleView) root.addView(gestureLayer) - - // 버튼 배치 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) }) @@ -122,37 +161,89 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { 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() { val gestureLayer = (videoTextureView.parent as FrameLayout).getChildAt(2) as android.widget.LinearLayout val left = gestureLayer.getChildAt(0) val center = gestureLayer.getChildAt(1) val right = gestureLayer.getChildAt(2) - // 1. 가운데: 재생/일시정지 center.setOnClickListener { isPlaying = !isPlaying - if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture)) + if (isPlaying) nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!)) else nativePlayer?.pause() } - // 2. 오른쪽: 롱프레스 4배속 val rightDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { override fun onLongPress(e: MotionEvent) { nativePlayer?.setSpeed(4.0f) } override fun onSingleTapUp(e: MotionEvent): Boolean { - nativePlayer?.seekBy(20.0) // 탭하면 10초 앞으로 + nativePlayer?.seekBy(20.0) return true } }) - right.setOnTouchListener { v, event -> + right.setOnTouchListener { _, event -> rightDetector.onTouchEvent(event) if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { nativePlayer?.setSpeed(1.0f) } true - } - // 3. 왼쪽: 롱프레스 주기적 뒤로가기 val leftDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { override fun onLongPress(e: MotionEvent) { leftLongPressJob = CoroutineScope(Dispatchers.Main).launch { @@ -163,11 +254,11 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { } } override fun onSingleTapUp(e: MotionEvent): Boolean { - nativePlayer?.seekBy(-10.0) // 탭하면 10초 뒤로 + nativePlayer?.seekBy(-10.0) return true } }) - left.setOnTouchListener { v, event -> + left.setOnTouchListener { _, event -> leftDetector.onTouchEvent(event) if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { leftLongPressJob?.cancel() @@ -178,16 +269,11 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) - - // 시스템 UI(상태바 등)를 다시 숨깁니다. 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) { runOnUiThread { // 1. 현재 기기의 실제 가용 화면 크기를 가져옵니다. @@ -242,27 +328,238 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { private fun cleanSubtitleText(text: String): String = text.replace(Regex("\\{.*?\\}"), "") - override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) { - val file = File(videoPath) - if (file.exists()) { - val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) - nativePlayer?.setDataSource(pfd.detachFd(), subtitlePath) - nativePlayer?.play(Surface(st)) + var lastSubTitle : String = "" + private fun startSubtitleSyncLoop() { + subtitleSyncJob?.cancel() + subtitleSyncJob = CoroutineScope(Dispatchers.Main).launch { + while (isActive) { + 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 { + val result = mutableListOf() + 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() { - window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + 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 onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { nativePlayer?.stop(); return true } 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() { super.onDestroy() nativePlayer?.destroy() leftLongPressJob?.cancel() + subtitleSyncJob?.cancel() } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_translator.xml b/app/src/main/res/layout/activity_translator.xml new file mode 100644 index 00000000..ab94f8b6 --- /dev/null +++ b/app/src/main/res/layout/activity_translator.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + +