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