diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 92c08fe6..bbb8c5be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -106,6 +106,15 @@ + + + + +#include +#include +#include +#include +#include + +#define LOG_TAG "NativePlayerEngine" +#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); + if (ret == 0) return AVERROR_EOF; + if (ret < 0) return AVERROR(errno); + return ret; +} + +static int64_t custom_seek_packet(void *opaque, int64_t offset, int whence) { + int fd = (int)(intptr_t)opaque; + if (whence == AVSEEK_SIZE) { + struct stat st; + if (fstat(fd, &st) == 0) return st.st_size; + return AVERROR(errno); + } + whence &= ~AVSEEK_FORCE; + int64_t ret = lseek(fd, offset, 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 { + jmethodID id = env->GetMethodID(clazz, name, sig); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + LOGE("โŒ Failed to find method: %s %s", name, sig); + return nullptr; + } + return id; + }; + + subtitleMethodId_ = getMethod("onSubtitleTextDecoded", "(Ljava/lang/String;)V"); + videoSizeMethodId_ = getMethod("onVideoSizeChanged", "(II)V"); +} + +PlayerEngine::~PlayerEngine() { + stop(); + if (listenerObj_) { + JNIEnv* env; + jvm_->GetEnv((void**)&env, JNI_VERSION_1_6); + env->DeleteGlobalRef(listenerObj_); + } +} + +void PlayerEngine::setDataSource(int videoFd, const std::string& subtitlePath) { + videoFd_ = videoFd; + subtitlePath_ = subtitlePath; +} + +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::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::pause() { + + isPaused_ = true; +} + +// [3. ๋ฉ”์ธ ๋ Œ๋”๋ง ๋ฃจํ”„] +void PlayerEngine::renderLoop() { + LOGI("Player render loop started (AAudio Mode)"); + + int avio_buffer_size = 32768; + uint8_t* avio_buffer = (uint8_t*)av_malloc(avio_buffer_size); + AVIOContext* avio_ctx = avio_alloc_context(avio_buffer, avio_buffer_size, 0, (void*)(intptr_t)videoFd_, custom_read_packet, nullptr, custom_seek_packet); + 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); + + 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; + } + + // ๋น„๋””์˜ค ์ดˆ๊ธฐํ™” + 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(); + } + } + + // ๐Ÿ’ก [AAudio ์ดˆ๊ธฐํ™” ๋ณต๊ตฌ] + if (audio_stream_idx_ >= 0) { + auto par = fmt_ctx_->streams[audio_stream_idx_]->codecpar; + auto codec = avcodec_find_decoder(par->codec_id); + audio_codec_ctx_ = avcodec_alloc_context3(codec); + avcodec_parameters_to_context(audio_codec_ctx_, par); + if (avcodec_open2(audio_codec_ctx_, codec, nullptr) == 0) { + AVChannelLayout out_ch; av_channel_layout_default(&out_ch, 2); + swr_alloc_set_opts2(&swr_ctx_, &out_ch, AV_SAMPLE_FMT_S16, 48000, &audio_codec_ctx_->ch_layout, audio_codec_ctx_->sample_fmt, audio_codec_ctx_->sample_rate, 0, nullptr); + swr_init(swr_ctx_); + + AAudioStreamBuilder* builder; + AAudio_createStreamBuilder(&builder); + AAudioStreamBuilder_setFormat(builder, AAUDIO_FORMAT_PCM_I16); + AAudioStreamBuilder_setChannelCount(builder, 2); + AAudioStreamBuilder_setSampleRate(builder, 48000); + AAudioStreamBuilder_openStream(builder, &audio_stream_); + AAudioStream_requestStart(audio_stream_); + AAudioStreamBuilder_delete(builder); + } + } + + 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; // ๐Ÿ’ก ์ผ์‹œ์ •์ง€ ์ค‘์ด๋ฉด ๋ฃจํ”„๋ฅผ ๋Œ๋ฉฐ ๋Œ€๊ธฐ๋งŒ ํ•จ + } + // [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_); } + 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) { + currentPosSec_ = frame->pts * av_q2d(fmt_ctx_->streams[video_stream_idx_]->time_base); + if (window_) { + int w = ANativeWindow_getWidth(window_), h = ANativeWindow_getHeight(window_); + if (!sws_ctx_ || w != last_win_w || h != last_win_h) { + if (sws_ctx_) sws_freeContext(sws_ctx_); + sws_ctx_ = sws_getContext(frame->width, frame->height, video_codec_ctx_->pix_fmt, w, h, AV_PIX_FMT_RGBA, SWS_BILINEAR, nullptr, nullptr, nullptr); + last_win_w = w; last_win_h = h; + } + ANativeWindow_Buffer buffer; + if (ANativeWindow_lock(window_, &buffer, nullptr) == 0) { + uint8_t* dst_data[4] = { (uint8_t*)buffer.bits, nullptr, nullptr, nullptr }; + int dst_line[4] = { buffer.stride * 4, 0, 0, 0 }; + if (sws_ctx_) sws_scale(sws_ctx_, frame->data, frame->linesize, 0, frame->height, dst_data, dst_line); + ANativeWindow_unlockAndPost(window_); + } + } + } + // ๐Ÿ’ก ๋ฐฐ์† ์ค‘์ด๊ฑฐ๋‚˜ ์˜ค๋””์˜ค๊ฐ€ ์—†์œผ๋ฉด 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) { // ์ •๋ฐฐ์†์ผ ๋•Œ๋งŒ ์žฌ์ƒ + 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); + + 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; + // ์ž๋ง‰ ํŒจํ‚ท ๋””์ฝ”๋”ฉ + 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); + } + } + 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); + if (swr_ctx_) swr_free(&swr_ctx_); + if (sws_ctx_) sws_freeContext(sws_ctx_); + if (video_codec_ctx_) avcodec_free_context(&video_codec_ctx_); + 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 diff --git a/app/src/main/cpp/PlayerEngine.h b/app/src/main/cpp/PlayerEngine.h new file mode 100644 index 00000000..66d774d5 --- /dev/null +++ b/app/src/main/cpp/PlayerEngine.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include +#include +#include +#include +#include // ๐Ÿ’ก AAudio ์ถ”๊ฐ€ + +extern "C" { +#include +#include +#include +#include +#include +#include +} + +class PlayerEngine { +public: + PlayerEngine(JavaVM* vm, jobject listenerObj); + ~PlayerEngine(); + + void setDataSource(int videoFd, const std::string& subtitlePath); + void play(ANativeWindow* window); + void pause(); + void stop(); + void seekBy(double seconds); + void setSpeed(float speed); + +private: + void renderLoop(); + void sendSubtitleToKotlin(const char* text); + + + int videoFd_ = -1; + std::string subtitlePath_; + std::atomic isPlaying_{false}; + std::atomic isPaused_{false}; // ๐Ÿ’ก ์ผ์‹œ์ •์ง€ ํ”Œ๋ž˜๊ทธ ์ถ”๊ฐ€ + std::thread renderThread_; + ANativeWindow* window_ = nullptr; + + std::atomic seekReq_{false}; + std::atomic seekTargetOffset_{0.0}; + std::atomic playbackSpeed_{1.0f}; + double currentPosSec_ = 0.0; + + AVFormatContext* fmt_ctx_ = nullptr; + AVCodecContext* video_codec_ctx_ = nullptr; + AVCodecContext* sub_codec_ctx_ = nullptr; + AVCodecContext* audio_codec_ctx_ = nullptr; + SwsContext* sws_ctx_ = nullptr; + SwrContext* swr_ctx_ = nullptr; + + int video_stream_idx_ = -1; + int sub_stream_idx_ = -1; + int audio_stream_idx_ = -1; + + AAudioStream* audio_stream_ = nullptr; // ๐Ÿ’ก AAudio ๋ณต๊ตฌ + + JavaVM* jvm_ = nullptr; + jobject listenerObj_ = nullptr; + jmethodID subtitleMethodId_ = nullptr; + jmethodID videoSizeMethodId_ = nullptr; +}; \ No newline at end of file diff --git a/app/src/main/cpp/native_player.cpp b/app/src/main/cpp/native_player.cpp new file mode 100644 index 00000000..6c48e246 --- /dev/null +++ b/app/src/main/cpp/native_player.cpp @@ -0,0 +1,85 @@ +// +// 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); +} + +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); +} + +JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativeSeekBy(JNIEnv *env, jobject thiz, jlong handle, jdouble seconds) { + PlayerEngine* engine = toPlayerNative(handle); + if (engine) engine->seekBy(seconds); +} + +JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativeSetSpeed(JNIEnv *env, jobject thiz, jlong handle, jfloat speed) { + PlayerEngine* engine = toPlayerNative(handle); + if (engine) engine->setSpeed(speed); +} + + +JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativeSetDataSource(JNIEnv *env, jobject thiz, jlong handle, jint videoFd, jstring jSubPath) { + PlayerEngine* engine = toPlayerNative(handle); + if (engine) { + const char* subPath = env->GetStringUTFChars(jSubPath, nullptr); + + // ์—”์ง„์œผ๋กœ FD ๋ฒˆํ˜ธ๋ฅผ ์ „๋‹ฌ + engine->setDataSource(videoFd, subPath); + + env->ReleaseStringUTFChars(jSubPath, subPath); + } +} + +JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativePause(JNIEnv *env, jobject thiz, jlong handle) { + PlayerEngine* engine = toPlayerNative(handle); + 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); + } +} + +JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativeStop(JNIEnv *env, jobject thiz, jlong handle) { + PlayerEngine* engine = toPlayerNative(handle); + if (engine) engine->stop(); +} + +JNIEXPORT void JNICALL +Java_bums_lunatic_launcher_player_NativePlayer_nativeDestroy(JNIEnv *env, jobject thiz, jlong handle) { + PlayerEngine* engine = toPlayerNative(handle); + if (engine) delete engine; +} + +} \ No newline at end of file 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 5878ccc1..f0810cbb 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt @@ -21,13 +21,13 @@ import android.widget.Spinner import android.widget.TextView import android.widget.Toast import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AlertDialog import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import bums.lunatic.launcher.R +import bums.lunatic.launcher.player.PlayerActivity import com.bumptech.glide.Glide import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -166,7 +166,14 @@ class CompletedFilesFragment : Fragment() { currentDir = file loadFiles() } else { - openPrivateFile(requireContext(), file) + if (extVideos.contains(file.extension.lowercase())) { + val intent = Intent(requireContext(), PlayerActivity::class.java).apply { + putExtra("VIDEO_PATH", file.absolutePath) + } + startActivity(intent) + } else { + openPrivateFile(requireContext(), file) // ์ด๋ฏธ์ง€๋‚˜ ๋ฌธ์„œ๋Š” ๊ธฐ์กด์ฒ˜๋Ÿผ + } } } }, 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 58d303fb..42129ca6 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt @@ -339,12 +339,10 @@ open class GeckoWeb @JvmOverloads constructor( if (element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE || element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO) { + showContextMenu(pageUrl, mediaUrl) + // ๋กœ๊ทธ์ธ ํ™•์ธ ํ›„ ๋ฉ”๋‰ด ํ‘œ์‹œ - BookmarkUploader.loginAndGetToken( - userId = "lunaticbum", userPw = "VioPup*383", - onSuccess = { showContextMenu(pageUrl, mediaUrl); context.toast("๋กœ๊ทธ์ธ ์„ฑ๊ณต") }, - onError = { context.toast("๋กœ๊ทธ์ธ ์‹คํŒจ: $it") } - ) + } else { super.onContextMenu(session, screenX, screenY, element) } @@ -782,8 +780,18 @@ open class GeckoWeb @JvmOverloads constructor( val menuItems = arrayOf("์ด ๋ฏธ๋””์–ด๋งŒ ๋ถ๋งˆํฌ", "ํŽ˜์ด์ง€์˜ ๋ชจ๋“  ์ด๋ฏธ์ง€ ๋ถ๋งˆํฌ", "๋‹ค์šด๋กœ๋“œ") AlertDialog.Builder(context).setTitle("์ž‘์—… ์„ ํƒ").setItems(menuItems) { _, which -> when (menuItems[which]) { - "์ด ๋ฏธ๋””์–ด๋งŒ ๋ถ๋งˆํฌ" -> startBookmarkSaveProcessForSingleImage(pageUrl, mediaUrl) - "ํŽ˜์ด์ง€์˜ ๋ชจ๋“  ์ด๋ฏธ์ง€ ๋ถ๋งˆํฌ" -> sendJsonMsg("fetchAllImages", "targetSrc" to mediaUrl) + "์ด ๋ฏธ๋””์–ด๋งŒ ๋ถ๋งˆํฌ" -> + BookmarkUploader.loginAndGetToken( + userId = "lunaticbum", userPw = "VioPup*383", + onSuccess = { context.toast("๋กœ๊ทธ์ธ ์„ฑ๊ณต");startBookmarkSaveProcessForSingleImage(pageUrl, mediaUrl) }, + onError = { context.toast("๋กœ๊ทธ์ธ ์‹คํŒจ: $it") } + ) + "ํŽ˜์ด์ง€์˜ ๋ชจ๋“  ์ด๋ฏธ์ง€ ๋ถ๋งˆํฌ" -> + BookmarkUploader.loginAndGetToken( + userId = "lunaticbum", userPw = "VioPup*383", + onSuccess = { context.toast("๋กœ๊ทธ์ธ ์„ฑ๊ณต");sendJsonMsg("fetchAllImages", "targetSrc" to mediaUrl) }, + onError = { context.toast("๋กœ๊ทธ์ธ ์‹คํŒจ: $it") } + ) "๋‹ค์šด๋กœ๋“œ" -> CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), mediaUrl) } }.show() diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt index 90bd7252..d3dd808e 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/tokiz/TokiFragment.kt @@ -617,10 +617,10 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan } } - private var originalVolume: Int = -1 - private val audioManager by lazy { - requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager - } +// private var originalVolume: Int = -1 +// private val audioManager by lazy { +// requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager +// } override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) saveContinuation = false @@ -836,21 +836,21 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan override fun onResume() { super.onResume() - originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - if (contentsType.contains("youtube")) { - // ๋ฏธ๋””์–ด ๋ณผ๋ฅจ์„ 0์œผ๋กœ ์„ค์ • (FLAG_SHOW_UI๋ฅผ 0์œผ๋กœ ์ฃผ๋ฉด ๋ณผ๋ฅจ ๋ฐ”๊ฐ€ ๋œจ์ง€ ์•Š์Œ) - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0) - } - - Blog.LOGE("RssHome ํ™œ์„ฑํ™”: ๋ฏธ๋””์–ด ๋ณผ๋ฅจ 0 ์„ค์ • (์ด์ „ ๋ณผ๋ฅจ: $originalVolume)") +// originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) +// if (contentsType.contains("youtube")) { +// // ๋ฏธ๋””์–ด ๋ณผ๋ฅจ์„ 0์œผ๋กœ ์„ค์ • (FLAG_SHOW_UI๋ฅผ 0์œผ๋กœ ์ฃผ๋ฉด ๋ณผ๋ฅจ ๋ฐ”๊ฐ€ ๋œจ์ง€ ์•Š์Œ) +// audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0) +// } +// +// Blog.LOGE("RssHome ํ™œ์„ฑํ™”: ๋ฏธ๋””์–ด ๋ณผ๋ฅจ 0 ์„ค์ • (์ด์ „ ๋ณผ๋ฅจ: $originalVolume)") } override fun onPause() { super.onPause() - if (originalVolume != -1 && contentsType.contains("youtube")) { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, originalVolume, 0) - Blog.LOGE("RssHome ๋น„ํ™œ์„ฑํ™”: ๋ฏธ๋””์–ด ๋ณผ๋ฅจ ๋ณต๊ตฌ ($originalVolume)") - } +// if (originalVolume != -1 && contentsType.contains("youtube")) { +// audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, originalVolume, 0) +// Blog.LOGE("RssHome ๋น„ํ™œ์„ฑํ™”: ๋ฏธ๋””์–ด ๋ณผ๋ฅจ ๋ณต๊ตฌ ($originalVolume)") +// } } fun getLastinfo() : LastInfo? { diff --git a/app/src/main/kotlin/bums/lunatic/launcher/player/NativePlayer.kt b/app/src/main/kotlin/bums/lunatic/launcher/player/NativePlayer.kt new file mode 100644 index 00000000..2c6e0fef --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/NativePlayer.kt @@ -0,0 +1,53 @@ +package bums.lunatic.launcher.player + +import android.view.Surface + +class NativePlayer { + private var nativeHandle: Long = 0 + private var subtitleCallback: ((String) -> Unit)? = null + private var videoSizeCallback: ((Int, Int) -> Unit)? = null + + fun initialize(): Boolean { + nativeHandle = nativeInit() + return nativeHandle != 0L + } + + fun setDataSource(videoFd: Int, subPath: String) = nativeSetDataSource(nativeHandle, videoFd, subPath) + fun play(surface: Surface) = nativePlay(nativeHandle, surface) + 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) { + subtitleCallback?.invoke(text) + } + + @Suppress("unused") + private fun onVideoSizeChanged(w: Int, h: Int) { + videoSizeCallback?.invoke(w, h) + } + + fun setSubtitleCallback(cb: (String) -> Unit) { subtitleCallback = cb } + fun setVideoSizeCallback(cb: (Int, Int) -> Unit) { videoSizeCallback = cb } + + fun destroy() { + if (nativeHandle != 0L) { + nativeDestroy(nativeHandle) + nativeHandle = 0L + } + } + private external fun nativePause(h: Long) + private external fun nativeInit(): Long + private external fun nativeSetDataSource(h: Long, fd: Int, sub: String) + private external fun nativePlay(h: Long, s: Surface) + 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) + + companion object { + init { System.loadLibrary("native_renderer") } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt new file mode 100644 index 00000000..dcf6e610 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt @@ -0,0 +1,267 @@ +package bums.lunatic.launcher.player + +import android.content.pm.ActivityInfo +import android.graphics.Color +import android.graphics.SurfaceTexture +import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.util.Log +import android.view.* +import android.widget.FrameLayout +import android.widget.ImageButton +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import bums.lunatic.launcher.R +import kotlinx.coroutines.* +import java.io.File + +class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { + + private lateinit var videoTextureView: TextureView + private lateinit var subtitleView: TextView + private lateinit var btnRotate: ImageButton + private lateinit var btnHideVideo: ImageButton + + private var nativePlayer: NativePlayer? = null + private var videoPath: String = "" + private var subtitlePath: String = "" + + private var isPlaying = true + private var isVideoHidden = false + private var leftLongPressJob: Job? = null + + private var videoWidth: Int = 0 + private var videoHeight: Int = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // 1. ๋ฐ์ดํ„ฐ ํ™•์ธ + videoPath = intent.getStringExtra("VIDEO_PATH") ?: "" + if (videoPath.isEmpty()) { finish(); return } + subtitlePath = findSubtitleFile(videoPath) + + // 2. UI ๋™์  ์ƒ์„ฑ ๋ฐ ๊ตฌ์„ฑ + 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 + } + } + } + + // 4. ์ œ์Šค์ฒ˜ ์„ค์ • + setupGestures() + } + + private fun setupUI() { + val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + + // ๋น„๋””์˜ค ๋ทฐ + videoTextureView = TextureView(this).apply { + surfaceTextureListener = this@PlayerActivity + } + + // ์ž๋ง‰ ๋ทฐ + subtitleView = TextView(this).apply { + setTextColor(Color.WHITE) + textSize = 22f + setShadowLayer(8f, 0f, 0f, Color.BLACK) + gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM + setPadding(40, 0, 40, 180) + } + + // ์ œ์Šค์ฒ˜ ๊ฐ์ง€์šฉ ํˆฌ๋ช… ๋ ˆ์ด์–ด (์‚ผ๋“ฑ๋ถ„) + val gestureLayer = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.HORIZONTAL + weightSum = 3f + } + + val leftZone = View(this).apply { id = View.generateViewId() } + val centerZone = View(this).apply { id = View.generateViewId() } + val rightZone = View(this).apply { id = View.generateViewId() } + + gestureLayer.addView(leftZone, 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)) + + // ์ปจํŠธ๋กค ๋ฒ„ํŠผ (์ขŒ์ธก ํ•˜๋‹จ ํšŒ์ „, ์šฐ์ธก ํ•˜๋‹จ ์ˆจ๊ธฐ๊ธฐ) + val controls = FrameLayout(this) + btnRotate = ImageButton(this).apply { + setImageResource(android.R.drawable.ic_menu_rotate) // ๊ธฐ๋ณธ ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ + setBackgroundColor(Color.TRANSPARENT) + setOnClickListener { toggleOrientation() } + } + btnHideVideo = ImageButton(this).apply { + setImageResource(android.R.drawable.ic_menu_close_clear_cancel) + setBackgroundColor(Color.TRANSPARENT) + setOnClickListener { toggleVideoVisibility() } + } + + 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) }) + + setContentView(root) + hideSystemUI() + } + + 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)) + 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์ดˆ ์•ž์œผ๋กœ + return true + } + }) + right.setOnTouchListener { v, 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 { + while (isActive) { + nativePlayer?.seekBy(-20.0) + delay(500) + } + } + } + override fun onSingleTapUp(e: MotionEvent): Boolean { + nativePlayer?.seekBy(-10.0) // ํƒญํ•˜๋ฉด 10์ดˆ ๋’ค๋กœ + return true + } + }) + left.setOnTouchListener { v, event -> + leftDetector.onTouchEvent(event) + if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + leftLongPressJob?.cancel() + } + true + } + } + + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + + // ์‹œ์Šคํ…œ UI(์ƒํƒœ๋ฐ” ๋“ฑ)๋ฅผ ๋‹ค์‹œ ์ˆจ๊น๋‹ˆ๋‹ค. + hideSystemUI() + + // ์ €์žฅํ•ด๋‘” ์˜์ƒ ํ•ด์ƒ๋„๊ฐ€ ์žˆ๋‹ค๋ฉด ํ˜„์žฌ ๋ฐ”๋€ ํ™”๋ฉด ํฌ๊ธฐ์— ๋งž์ถฐ ๋‹ค์‹œ ์ •๋ ฌํ•ฉ๋‹ˆ๋‹ค. + if (videoWidth > 0 && videoHeight > 0) { + adjustVideoAspectRatio(videoWidth, videoHeight) + } + } + + private fun adjustVideoAspectRatio(videoW: Int, videoH: Int) { + runOnUiThread { + // 1. ํ˜„์žฌ ๊ธฐ๊ธฐ์˜ ์‹ค์ œ ๊ฐ€์šฉ ํ™”๋ฉด ํฌ๊ธฐ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. + val displayMetrics = resources.displayMetrics + val screenW = displayMetrics.widthPixels + val screenH = displayMetrics.heightPixels + + // 2. ์ข…ํšก๋น„๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + val videoRatio = videoW.toFloat() / videoH.toFloat() + val screenRatio = screenW.toFloat() / screenH.toFloat() + + val lp = videoTextureView.layoutParams as FrameLayout.LayoutParams + + if (videoRatio > screenRatio) { + // ๐Ÿ’ก ์˜์ƒ์ด ํ™”๋ฉด๋ณด๋‹ค ๋” ๊ฐ€๋กœ๋กœ ๊ธด ๊ฒฝ์šฐ (๊ฐ€๋กœ๋ฅผ ๊ฝ‰ ์ฑ„์šฐ๊ณ  ์„ธ๋กœ๋Š” ๋น„์œจ๋Œ€๋กœ ์ถ•์†Œ) + lp.width = screenW + lp.height = (screenW / videoRatio).toInt() + } else { + // ๐Ÿ’ก ์˜์ƒ์ด ํ™”๋ฉด๋ณด๋‹ค ๋” ์„ธ๋กœ๋กœ ๊ธด ๊ฒฝ์šฐ (์„ธ๋กœ๋ฅผ ๊ฝ‰ ์ฑ„์šฐ๊ณ  ๊ฐ€๋กœ๋Š” ๋น„์œจ๋Œ€๋กœ ์ถ•์†Œ) + lp.width = (screenH * videoRatio).toInt() + lp.height = screenH + } + + // 3. ๋ทฐ๋ฅผ ํ™”๋ฉด ์ •์ค‘์•™์— ๋ฐฐ์น˜ํ•ฉ๋‹ˆ๋‹ค. + lp.gravity = Gravity.CENTER + videoTextureView.layoutParams = lp + + Log.d("Player", "Video Resized: ${lp.width}x${lp.height} for Screen: ${screenW}x${screenH}") + } + } + + private fun toggleOrientation() { + requestedOrientation = if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + + private fun toggleVideoVisibility() { + isVideoHidden = !isVideoHidden + videoTextureView.visibility = if (isVideoHidden) View.INVISIBLE else View.VISIBLE + } + + private fun findSubtitleFile(videoPath: String): String { + val file = File(videoPath) + val name = file.nameWithoutExtension + val extensions = listOf("srt", "ass", "smi") + for (ext in extensions) { + val sub = File(file.parent, "$name.$ext") + if (sub.exists()) return sub.absolutePath + } + return "" + } + + 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)) + } + } + + 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) + } + + override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {} + override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { nativePlayer?.stop(); return true } + override fun onSurfaceTextureUpdated(st: SurfaceTexture) {} + + override fun onDestroy() { + super.onDestroy() + nativePlayer?.destroy() + leftLongPressJob?.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml new file mode 100644 index 00000000..77d9ef65 --- /dev/null +++ b/app/src/main/res/layout/activity_player.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file