diff --git a/app/src/main/cpp/PlayerEngine.cpp b/app/src/main/cpp/PlayerEngine.cpp index 0b8a44ab..97f603ff 100644 --- a/app/src/main/cpp/PlayerEngine.cpp +++ b/app/src/main/cpp/PlayerEngine.cpp @@ -414,7 +414,10 @@ void PlayerEngine::readThreadLoop() { // 4. 코덱 내부에 남아있는 옛날 프레임 찌꺼기 초기화 // if (video_codec_ctx_) avcodec_flush_buffers(video_codec_ctx_); +// videoCodecFlushReq_ = false; // if (audio_codec_ctx_) avcodec_flush_buffers(audio_codec_ctx_); +// // 이미 비웠으므로 false +// audioCodecFlushReq_ = false; // if (sonic_stream_) { // sonicFlushStream(sonic_stream_); // } @@ -541,6 +544,7 @@ void PlayerEngine::audioThreadLoop() { while (framesLeft > 0 && !abortRequest_) { int32_t framesWritten = AAudioStream_write(audio_stream_, currentAAudioBuf, framesLeft, 1000000000); + if (seekReq_) break; if (framesWritten < 0) break; framesLeft -= framesWritten; currentAAudioBuf += framesWritten * 2; // 2채널 전진 diff --git a/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt b/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt index f4826dff..2f7d53ed 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt @@ -251,150 +251,150 @@ class AggregatedNewsWorker(private val context: Context, params: WorkerParameter -class WallContentsWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - - // 1. 필요한 Gemini 응답 데이터 클래스 추가 - data class GeminiResponse(val candidates: List) - data class Candidate(val content: Content) - data class Content(val parts: List) - data class Part(val text: String) - - // 불필요한 최상단 따옴표를 제거한 깔끔한 프롬프트 - - - override suspend fun doWork(): Result { - return try { - refreshGeminiContent() - Result.success() - } catch (e: Exception) { - Blog.LOGE("WallContentsWorker Failed", e) - Result.retry() // 일시적 오류일 수 있으므로 재시도 반환 - } - } - - private fun getTimeBasedContext(): String { - val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) - - return when (hour) { - in 6..10 -> "지금은 아침 시간이야. 카페에서 커피/브런치를 주문하거나, 상쾌하게 아침 인사를 나누는 상황에 맞는 가벼운 스몰토크를 알려줘." - in 11..16 -> "지금은 낮 시간이야. 관광지에서 길을 묻거나, 점심 식사 주문, 상점에서 물건을 구매할 때 쓰는 활동적인 실생활 표현을 알려줘." - in 17..21 -> "지금은 저녁 시간이야. 분위기 있는 식당에서 저녁을 먹거나, 펍에서 맥주 한잔하며 현지인과 가볍게 나누는 스몰토크를 알려줘." - else -> "지금은 늦은 밤이야. 숙소(호텔) 프론트에 문의하거나, 편의점에서 야식을 사고, 내일 일정을 묻는 등 밤 시간에 필요한 실용적인 표현을 알려줘." - } - } - - private suspend fun refreshGeminiContent() { - val apiKey = PrefString.geminiApiKey.get("") // 설정에서 가져온 키 - if (apiKey.isEmpty()) { - Blog.LOGE("Gemini API Key is empty.") - return - } - - val resultJson = callGeminiApi(apiKey, getDynamicPrompt()) ?: return - Blog.LOGE("resultJson >>> $resultJson") - val newGroups = parseGeminiResponse(resultJson) ?: return - Blog.LOGE("newGroups.size ${newGroups.size}") - WorkersDb.insertExpressions(newGroups) - - } - - private fun getDynamicPrompt(): String { - val myStyle = PrefString.travelStyle.get("주로 현지 로컬 맛집을 찾아다니고, 가벼운 스몰토크를 즐기는 편이야. 흡연 및 음주를 즐기는 편이고, 여행은 항상 부인과 같이 다녀") - val timeContext = getTimeBasedContext() - - return """ - 너는 여행 및 외국어 학습 전문가야. 아래의 [나의 스타일]과 [현재 상황]을 반영해서, 당장 써먹을 수 있는 유용한 실용 회화 표현 12가지를 선정해줘. - - [나의 스타일]: $myStyle - [현재 상황]: $timeContext - - 각 표현에 대해 영어(EN), 스페인어(ES), 일본어(JA), 중국어(ZH) 네 가지 버전을 제공해. - - 중요 규칙: - - 모든 외국어 문장의 'pron'에는 최대한 정확한 한국어 발음을 적어줘. - - 일본어(JA)는 한자와 가나 혼용, 중국어(ZH)는 간체자를 쓰고 'pron'에 병음과 한글 발음을 적어줘. - - 각 번역본마다 그 문장에서 가장 중요한 '핵심 단어나 숙어' 2~3개를 뽑아서 `words` 배열에 원단어(word)와 뜻(meaning)으로 정리해줘. - - 반드시 아래 JSON 배열 형식으로만 응답해: - [ - { - "topic": "주제", - "translations": [ - { - "lang": "EN", - "main": "Sentence", - "sub": "한국어 뜻", - "pron": "한글 발음", - "words": [ - {"word": "단어1", "meaning": "뜻1", "pron": "단어1 발음"}, - {"word": "단어2", "meaning": "뜻2", "pron": "단어2 발음"} - ] - }, - ... (ES, JA, ZH도 동일한 구조) - ] - } - ] - // (이 형식으로 총 12개 세트를 제공할 것) - """.trimIndent() - } - - private suspend fun callGeminiApi(apiKey: String, prompt: String): String? { - return withContext(Dispatchers.IO) { - val client = OkHttpClient.Builder() - .connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간 - .readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!) - .writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간 - .build() - val url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey" - - // 3. Gson을 사용하여 JSON 구조를 안전하게 생성 (따옴표 깨짐 방지) - val requestMap = mapOf( - "contents" to listOf( - mapOf("parts" to listOf(mapOf("text" to prompt))) - ), - "generationConfig" to mapOf( - "responseMimeType" to "application/json" - ) - ) - - val jsonRequest = Gson().toJson(requestMap) - - val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - val request = Request.Builder() - .url(url) - .post(body) - .build() - - try { - val response = client.newCall(request).execute() - if (response.isSuccessful) { - response.body?.string() - } else { - Blog.LOGE("Gemini API Error: ${response.code} ${response.message}") - null - } - } catch (e: Exception) { - e.printStackTrace() - null - } - } - } - - private fun parseGeminiResponse(jsonString: String): List? { - return try { - val gson = Gson() - val fullResponse = gson.fromJson(jsonString, GeminiResponse::class.java) - val innerJson = fullResponse.candidates[0].content.parts[0].text - - val groupType = object : TypeToken>() {}.type - gson.fromJson(innerJson, groupType) - } catch (e: Exception) { - Blog.LOGE("Parsing Error: ${e.message}") - null - } - } - -} +//class WallContentsWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { +// +// // 1. 필요한 Gemini 응답 데이터 클래스 추가 +// data class GeminiResponse(val candidates: List) +// data class Candidate(val content: Content) +// data class Content(val parts: List) +// data class Part(val text: String) +// +// // 불필요한 최상단 따옴표를 제거한 깔끔한 프롬프트 +// +// +// override suspend fun doWork(): Result { +// return try { +// refreshGeminiContent() +// Result.success() +// } catch (e: Exception) { +// Blog.LOGE("WallContentsWorker Failed", e) +// Result.retry() // 일시적 오류일 수 있으므로 재시도 반환 +// } +// } +// +// private fun getTimeBasedContext(): String { +// val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) +// +// return when (hour) { +// in 6..10 -> "지금은 아침 시간이야. 카페에서 커피/브런치를 주문하거나, 상쾌하게 아침 인사를 나누는 상황에 맞는 가벼운 스몰토크를 알려줘." +// in 11..16 -> "지금은 낮 시간이야. 관광지에서 길을 묻거나, 점심 식사 주문, 상점에서 물건을 구매할 때 쓰는 활동적인 실생활 표현을 알려줘." +// in 17..21 -> "지금은 저녁 시간이야. 분위기 있는 식당에서 저녁을 먹거나, 펍에서 맥주 한잔하며 현지인과 가볍게 나누는 스몰토크를 알려줘." +// else -> "지금은 늦은 밤이야. 숙소(호텔) 프론트에 문의하거나, 편의점에서 야식을 사고, 내일 일정을 묻는 등 밤 시간에 필요한 실용적인 표현을 알려줘." +// } +// } +// +// private suspend fun refreshGeminiContent() { +// val apiKey = PrefString.geminiApiKey.get("") // 설정에서 가져온 키 +// if (apiKey.isEmpty()) { +// Blog.LOGE("Gemini API Key is empty.") +// return +// } +// +// val resultJson = callGeminiApi(apiKey, getDynamicPrompt()) ?: return +// Blog.LOGE("resultJson >>> $resultJson") +// val newGroups = parseGeminiResponse(resultJson) ?: return +// Blog.LOGE("newGroups.size ${newGroups.size}") +// WorkersDb.insertExpressions(newGroups) +// +// } +// +// private fun getDynamicPrompt(): String { +// val myStyle = PrefString.travelStyle.get("주로 현지 로컬 맛집을 찾아다니고, 가벼운 스몰토크를 즐기는 편이야. 흡연 및 음주를 즐기는 편이고, 여행은 항상 부인과 같이 다녀") +// val timeContext = getTimeBasedContext() +// +// return """ +// 너는 여행 및 외국어 학습 전문가야. 아래의 [나의 스타일]과 [현재 상황]을 반영해서, 당장 써먹을 수 있는 유용한 실용 회화 표현 12가지를 선정해줘. +// +// [나의 스타일]: $myStyle +// [현재 상황]: $timeContext +// +// 각 표현에 대해 영어(EN), 스페인어(ES), 일본어(JA), 중국어(ZH) 네 가지 버전을 제공해. +// +// 중요 규칙: +// - 모든 외국어 문장의 'pron'에는 최대한 정확한 한국어 발음을 적어줘. +// - 일본어(JA)는 한자와 가나 혼용, 중국어(ZH)는 간체자를 쓰고 'pron'에 병음과 한글 발음을 적어줘. +// - 각 번역본마다 그 문장에서 가장 중요한 '핵심 단어나 숙어' 2~3개를 뽑아서 `words` 배열에 원단어(word)와 뜻(meaning)으로 정리해줘. +// +// 반드시 아래 JSON 배열 형식으로만 응답해: +// [ +// { +// "topic": "주제", +// "translations": [ +// { +// "lang": "EN", +// "main": "Sentence", +// "sub": "한국어 뜻", +// "pron": "한글 발음", +// "words": [ +// {"word": "단어1", "meaning": "뜻1", "pron": "단어1 발음"}, +// {"word": "단어2", "meaning": "뜻2", "pron": "단어2 발음"} +// ] +// }, +// ... (ES, JA, ZH도 동일한 구조) +// ] +// } +// ] +// // (이 형식으로 총 12개 세트를 제공할 것) +// """.trimIndent() +// } +// +// private suspend fun callGeminiApi(apiKey: String, prompt: String): String? { +// return withContext(Dispatchers.IO) { +// val client = OkHttpClient.Builder() +// .connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간 +// .readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!) +// .writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간 +// .build() +// val url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey" +// +// // 3. Gson을 사용하여 JSON 구조를 안전하게 생성 (따옴표 깨짐 방지) +// val requestMap = mapOf( +// "contents" to listOf( +// mapOf("parts" to listOf(mapOf("text" to prompt))) +// ), +// "generationConfig" to mapOf( +// "responseMimeType" to "application/json" +// ) +// ) +// +// val jsonRequest = Gson().toJson(requestMap) +// +// val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) +// val request = Request.Builder() +// .url(url) +// .post(body) +// .build() +// +// try { +// val response = client.newCall(request).execute() +// if (response.isSuccessful) { +// response.body?.string() +// } else { +// Blog.LOGE("Gemini API Error: ${response.code} ${response.message}") +// null +// } +// } catch (e: Exception) { +// e.printStackTrace() +// null +// } +// } +// } +// +// private fun parseGeminiResponse(jsonString: String): List? { +// return try { +// val gson = Gson() +// val fullResponse = gson.fromJson(jsonString, GeminiResponse::class.java) +// val innerJson = fullResponse.candidates[0].content.parts[0].text +// +// val groupType = object : TypeToken>() {}.type +// gson.fromJson(innerJson, groupType) +// } catch (e: Exception) { +// Blog.LOGE("Parsing Error: ${e.message}") +// null +// } +// } +// +//} @@ -718,22 +718,6 @@ class ForeGroundService : Service() { workManager.enqueueUniquePeriodicWork("AggregatedNewsWork", ExistingPeriodicWorkPolicy.KEEP, newsRequest) -// val wallpaperRequest = PeriodicWorkRequestBuilder( -// 2, TimeUnit.HOURS // 1시간 간격 설정 -// ) -// .setConstraints( -// Constraints.Builder() -// .setRequiredNetworkType(NetworkType.CONNECTED) // 네트워크 연결 시에만 -// .build() -// ) -// .build() - -// workManager.enqueueUniquePeriodicWork( -// "WallContentsWork", -// ExistingPeriodicWorkPolicy.KEEP, -// wallpaperRequest -// ) - val quoteRequest = PeriodicWorkRequestBuilder( 2, TimeUnit.HOURS // 1시간 간격 설정 ) 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 9c96326f..47e37641 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/player/PlayerActivity.kt @@ -56,6 +56,51 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { private var subtitleSyncJob: Job? = null private val allSubtitleTracks = mutableListOf() + private var lastCheckedPos = -1.0 + private var lastCheckedTime = 0L + private var isRecovering = false + + private fun rebootEngine(resumePos: Double) { + isRecovering = true + Toast.makeText(this, "재생 환경을 복구하는 중입니다...", Toast.LENGTH_SHORT).show() + + // 1. 기존 엔진 중지 + nativePlayer?.stop() + + // 2. 다시 준비 (기존 경로 및 surface 활용) + // onPreparedListener에서 resumePos로 다시 seekTo 하도록 설정 + nativePlayer?.onPreparedListener = { + nativePlayer?.seekTo(resumePos) + nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!)) + isRecovering = false + lastCheckedTime = System.currentTimeMillis() // 타임스탬프 갱신 + } + + // 이전에 사용했던 FD를 다시 넘기거나 경로를 통해 다시 Open + prepareEngine() + } + + private fun prepareEngine() { + 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() + } + } + 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 @@ -93,12 +138,10 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { onPreparedListener = { runOnUiThread { loadAvailableSubtitles() - startUIUpdateLoop() if (allSubtitleTracks.size > 1) { showSubtitleSelectionDialog() } else { - // 자막 없으면 자동 시작 - play(Surface(videoTextureView.surfaceTexture!!)) + play() } } } @@ -231,30 +274,32 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { tvTime.text = "${formatTime(currentPos)} / ${formatTime(duration)}" } } + val currentPos = nativePlayer?.getCurrentPosition() ?: 0.0 + val now = System.currentTimeMillis() + Log.e("PlayerWatchdog", "isPlaying $isPlaying isUserSeeking $isUserSeeking isRecovering $isRecovering" ) + if (isPlaying && !isUserSeeking && !isRecovering) { + // 재생 위치가 변했는지 확인 + if (currentPos != lastCheckedPos) { + lastCheckedPos = currentPos + lastCheckedTime = now + } else { + // 위치가 그대로라면, 얼마나 오래 멈춰있었는지 확인 (3초 이상) + if (now - lastCheckedTime > 3000) { + Log.e("PlayerWatchdog", "영상이 멈춘 것으로 판단됨! 복구 시작...") + rebootEngine(currentPos) + } else { + + } + } + } + delay(500) // 0.5초마다 UI 갱신 } } } 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() - } + prepareEngine() } private fun loadAvailableSubtitles() { @@ -276,11 +321,16 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener { .setCancelable(false) .setItems(trackNames) { _, which -> selectSubtitleTrack(allSubtitleTracks[which]) - nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!)) + play() } .show() } + fun play() { + startUIUpdateLoop() + nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!)) + } + private fun selectSubtitleTrack(track: SubtitleTrack) { if (track.isExternal) { nativePlayer?.setInternalSubtitleTrack(-1)