This commit is contained in:
lunaticbum 2026-04-15 15:04:20 +09:00
parent fa5e6bbedc
commit 5ded732345
3 changed files with 220 additions and 182 deletions

View File

@ -414,7 +414,10 @@ void PlayerEngine::readThreadLoop() {
// 4. 코덱 내부에 남아있는 옛날 프레임 찌꺼기 초기화 // 4. 코덱 내부에 남아있는 옛날 프레임 찌꺼기 초기화
// if (video_codec_ctx_) avcodec_flush_buffers(video_codec_ctx_); // if (video_codec_ctx_) avcodec_flush_buffers(video_codec_ctx_);
// videoCodecFlushReq_ = false;
// if (audio_codec_ctx_) avcodec_flush_buffers(audio_codec_ctx_); // if (audio_codec_ctx_) avcodec_flush_buffers(audio_codec_ctx_);
// // 이미 비웠으므로 false
// audioCodecFlushReq_ = false;
// if (sonic_stream_) { // if (sonic_stream_) {
// sonicFlushStream(sonic_stream_); // sonicFlushStream(sonic_stream_);
// } // }
@ -541,6 +544,7 @@ void PlayerEngine::audioThreadLoop() {
while (framesLeft > 0 && !abortRequest_) { while (framesLeft > 0 && !abortRequest_) {
int32_t framesWritten = AAudioStream_write(audio_stream_, currentAAudioBuf, framesLeft, 1000000000); int32_t framesWritten = AAudioStream_write(audio_stream_, currentAAudioBuf, framesLeft, 1000000000);
if (seekReq_) break;
if (framesWritten < 0) break; if (framesWritten < 0) break;
framesLeft -= framesWritten; framesLeft -= framesWritten;
currentAAudioBuf += framesWritten * 2; // 2채널 전진 currentAAudioBuf += framesWritten * 2; // 2채널 전진

View File

@ -251,150 +251,150 @@ class AggregatedNewsWorker(private val context: Context, params: WorkerParameter
class WallContentsWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { //class WallContentsWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
//
// 1. 필요한 Gemini 응답 데이터 클래스 추가 // // 1. 필요한 Gemini 응답 데이터 클래스 추가
data class GeminiResponse(val candidates: List<Candidate>) // data class GeminiResponse(val candidates: List<Candidate>)
data class Candidate(val content: Content) // data class Candidate(val content: Content)
data class Content(val parts: List<Part>) // data class Content(val parts: List<Part>)
data class Part(val text: String) // data class Part(val text: String)
//
// 불필요한 최상단 따옴표를 제거한 깔끔한 프롬프트 // // 불필요한 최상단 따옴표를 제거한 깔끔한 프롬프트
//
//
override suspend fun doWork(): Result { // override suspend fun doWork(): Result {
return try { // return try {
refreshGeminiContent() // refreshGeminiContent()
Result.success() // Result.success()
} catch (e: Exception) { // } catch (e: Exception) {
Blog.LOGE("WallContentsWorker Failed", e) // Blog.LOGE("WallContentsWorker Failed", e)
Result.retry() // 일시적 오류일 수 있으므로 재시도 반환 // Result.retry() // 일시적 오류일 수 있으므로 재시도 반환
} // }
} // }
//
private fun getTimeBasedContext(): String { // private fun getTimeBasedContext(): String {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) // val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
//
return when (hour) { // return when (hour) {
in 6..10 -> "지금은 아침 시간이야. 카페에서 커피/브런치를 주문하거나, 상쾌하게 아침 인사를 나누는 상황에 맞는 가벼운 스몰토크를 알려줘." // in 6..10 -> "지금은 아침 시간이야. 카페에서 커피/브런치를 주문하거나, 상쾌하게 아침 인사를 나누는 상황에 맞는 가벼운 스몰토크를 알려줘."
in 11..16 -> "지금은 낮 시간이야. 관광지에서 길을 묻거나, 점심 식사 주문, 상점에서 물건을 구매할 때 쓰는 활동적인 실생활 표현을 알려줘." // in 11..16 -> "지금은 낮 시간이야. 관광지에서 길을 묻거나, 점심 식사 주문, 상점에서 물건을 구매할 때 쓰는 활동적인 실생활 표현을 알려줘."
in 17..21 -> "지금은 저녁 시간이야. 분위기 있는 식당에서 저녁을 먹거나, 펍에서 맥주 한잔하며 현지인과 가볍게 나누는 스몰토크를 알려줘." // in 17..21 -> "지금은 저녁 시간이야. 분위기 있는 식당에서 저녁을 먹거나, 펍에서 맥주 한잔하며 현지인과 가볍게 나누는 스몰토크를 알려줘."
else -> "지금은 늦은 밤이야. 숙소(호텔) 프론트에 문의하거나, 편의점에서 야식을 사고, 내일 일정을 묻는 등 밤 시간에 필요한 실용적인 표현을 알려줘." // else -> "지금은 늦은 밤이야. 숙소(호텔) 프론트에 문의하거나, 편의점에서 야식을 사고, 내일 일정을 묻는 등 밤 시간에 필요한 실용적인 표현을 알려줘."
} // }
} // }
//
private suspend fun refreshGeminiContent() { // private suspend fun refreshGeminiContent() {
val apiKey = PrefString.geminiApiKey.get("") // 설정에서 가져온 키 // val apiKey = PrefString.geminiApiKey.get("") // 설정에서 가져온 키
if (apiKey.isEmpty()) { // if (apiKey.isEmpty()) {
Blog.LOGE("Gemini API Key is empty.") // Blog.LOGE("Gemini API Key is empty.")
return // return
} // }
//
val resultJson = callGeminiApi(apiKey, getDynamicPrompt()) ?: return // val resultJson = callGeminiApi(apiKey, getDynamicPrompt()) ?: return
Blog.LOGE("resultJson >>> $resultJson") // Blog.LOGE("resultJson >>> $resultJson")
val newGroups = parseGeminiResponse(resultJson) ?: return // val newGroups = parseGeminiResponse(resultJson) ?: return
Blog.LOGE("newGroups.size ${newGroups.size}") // Blog.LOGE("newGroups.size ${newGroups.size}")
WorkersDb.insertExpressions(newGroups) // WorkersDb.insertExpressions(newGroups)
//
} // }
//
private fun getDynamicPrompt(): String { // private fun getDynamicPrompt(): String {
val myStyle = PrefString.travelStyle.get("주로 현지 로컬 맛집을 찾아다니고, 가벼운 스몰토크를 즐기는 편이야. 흡연 및 음주를 즐기는 편이고, 여행은 항상 부인과 같이 다녀") // val myStyle = PrefString.travelStyle.get("주로 현지 로컬 맛집을 찾아다니고, 가벼운 스몰토크를 즐기는 편이야. 흡연 및 음주를 즐기는 편이고, 여행은 항상 부인과 같이 다녀")
val timeContext = getTimeBasedContext() // val timeContext = getTimeBasedContext()
//
return """ // return """
너는 여행 외국어 학습 전문가야. 아래의 [나의 스타일] [현재 상황] 반영해서, 당장 써먹을 있는 유용한 실용 회화 표현 12가지를 선정해줘. // 너는 여행 및 외국어 학습 전문가야. 아래의 [나의 스타일]과 [현재 상황]을 반영해서, 당장 써먹을 수 있는 유용한 실용 회화 표현 12가지를 선정해줘.
//
[나의 스타일]: $myStyle // [나의 스타일]: $myStyle
[현재 상황]: $timeContext // [현재 상황]: $timeContext
//
표현에 대해 영어(EN), 스페인어(ES), 일본어(JA), 중국어(ZH) 가지 버전을 제공해. // 각 표현에 대해 영어(EN), 스페인어(ES), 일본어(JA), 중국어(ZH) 네 가지 버전을 제공해.
//
중요 규칙: // 중요 규칙:
- 모든 외국어 문장의 'pron'에는 최대한 정확한 한국어 발음을 적어줘. // - 모든 외국어 문장의 'pron'에는 최대한 정확한 한국어 발음을 적어줘.
- 일본어(JA) 한자와 가나 혼용, 중국어(ZH) 간체자를 쓰고 'pron' 병음과 한글 발음을 적어줘. // - 일본어(JA)는 한자와 가나 혼용, 중국어(ZH)는 간체자를 쓰고 'pron'에 병음과 한글 발음을 적어줘.
- 번역본마다 문장에서 가장 중요한 '핵심 단어나 숙어' 2~3개를 뽑아서 `words` 배열에 원단어(word) (meaning)으로 정리해줘. // - 각 번역본마다 그 문장에서 가장 중요한 '핵심 단어나 숙어' 2~3개를 뽑아서 `words` 배열에 원단어(word)와 뜻(meaning)으로 정리해줘.
//
반드시 아래 JSON 배열 형식으로만 응답해: // 반드시 아래 JSON 배열 형식으로만 응답해:
[ // [
{ // {
"topic": "주제", // "topic": "주제",
"translations": [ // "translations": [
{ // {
"lang": "EN", // "lang": "EN",
"main": "Sentence", // "main": "Sentence",
"sub": "한국어 뜻", // "sub": "한국어 뜻",
"pron": "한글 발음", // "pron": "한글 발음",
"words": [ // "words": [
{"word": "단어1", "meaning": "뜻1", "pron": "단어1 발음"}, // {"word": "단어1", "meaning": "뜻1", "pron": "단어1 발음"},
{"word": "단어2", "meaning": "뜻2", "pron": "단어2 발음"} // {"word": "단어2", "meaning": "뜻2", "pron": "단어2 발음"}
] // ]
}, // },
... (ES, JA, ZH도 동일한 구조) // ... (ES, JA, ZH도 동일한 구조)
] // ]
} // }
] // ]
// (이 형식으로 총 12개 세트를 제공할 것) // // (이 형식으로 총 12개 세트를 제공할 것)
""".trimIndent() // """.trimIndent()
} // }
//
private suspend fun callGeminiApi(apiKey: String, prompt: String): String? { // private suspend fun callGeminiApi(apiKey: String, prompt: String): String? {
return withContext(Dispatchers.IO) { // return withContext(Dispatchers.IO) {
val client = OkHttpClient.Builder() // val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간 // .connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간
.readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!) // .readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!)
.writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간 // .writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간
.build() // .build()
val url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey" // val url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey"
//
// 3. Gson을 사용하여 JSON 구조를 안전하게 생성 (따옴표 깨짐 방지) // // 3. Gson을 사용하여 JSON 구조를 안전하게 생성 (따옴표 깨짐 방지)
val requestMap = mapOf( // val requestMap = mapOf(
"contents" to listOf( // "contents" to listOf(
mapOf("parts" to listOf(mapOf("text" to prompt))) // mapOf("parts" to listOf(mapOf("text" to prompt)))
), // ),
"generationConfig" to mapOf( // "generationConfig" to mapOf(
"responseMimeType" to "application/json" // "responseMimeType" to "application/json"
) // )
) // )
//
val jsonRequest = Gson().toJson(requestMap) // val jsonRequest = Gson().toJson(requestMap)
//
val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) // val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
val request = Request.Builder() // val request = Request.Builder()
.url(url) // .url(url)
.post(body) // .post(body)
.build() // .build()
//
try { // try {
val response = client.newCall(request).execute() // val response = client.newCall(request).execute()
if (response.isSuccessful) { // if (response.isSuccessful) {
response.body?.string() // response.body?.string()
} else { // } else {
Blog.LOGE("Gemini API Error: ${response.code} ${response.message}") // Blog.LOGE("Gemini API Error: ${response.code} ${response.message}")
null // null
} // }
} catch (e: Exception) { // } catch (e: Exception) {
e.printStackTrace() // e.printStackTrace()
null // null
} // }
} // }
} // }
//
private fun parseGeminiResponse(jsonString: String): List<WallContentGroup>? { // private fun parseGeminiResponse(jsonString: String): List<WallContentGroup>? {
return try { // return try {
val gson = Gson() // val gson = Gson()
val fullResponse = gson.fromJson(jsonString, GeminiResponse::class.java) // val fullResponse = gson.fromJson(jsonString, GeminiResponse::class.java)
val innerJson = fullResponse.candidates[0].content.parts[0].text // val innerJson = fullResponse.candidates[0].content.parts[0].text
//
val groupType = object : TypeToken<List<WallContentGroup>>() {}.type // val groupType = object : TypeToken<List<WallContentGroup>>() {}.type
gson.fromJson(innerJson, groupType) // gson.fromJson(innerJson, groupType)
} catch (e: Exception) { // } catch (e: Exception) {
Blog.LOGE("Parsing Error: ${e.message}") // Blog.LOGE("Parsing Error: ${e.message}")
null // null
} // }
} // }
//
} //}
@ -718,22 +718,6 @@ class ForeGroundService : Service() {
workManager.enqueueUniquePeriodicWork("AggregatedNewsWork", ExistingPeriodicWorkPolicy.KEEP, newsRequest) workManager.enqueueUniquePeriodicWork("AggregatedNewsWork", ExistingPeriodicWorkPolicy.KEEP, newsRequest)
// val wallpaperRequest = PeriodicWorkRequestBuilder<WallContentsWorker>(
// 2, TimeUnit.HOURS // 1시간 간격 설정
// )
// .setConstraints(
// Constraints.Builder()
// .setRequiredNetworkType(NetworkType.CONNECTED) // 네트워크 연결 시에만
// .build()
// )
// .build()
// workManager.enqueueUniquePeriodicWork(
// "WallContentsWork",
// ExistingPeriodicWorkPolicy.KEEP,
// wallpaperRequest
// )
val quoteRequest = PeriodicWorkRequestBuilder<QuoteFetchWorker>( val quoteRequest = PeriodicWorkRequestBuilder<QuoteFetchWorker>(
2, TimeUnit.HOURS // 1시간 간격 설정 2, TimeUnit.HOURS // 1시간 간격 설정
) )

View File

@ -56,6 +56,51 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
private var subtitleSyncJob: Job? = null private var subtitleSyncJob: Job? = null
private val allSubtitleTracks = mutableListOf<SubtitleTrack>() private val allSubtitleTracks = mutableListOf<SubtitleTrack>()
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?) { override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
@ -93,12 +138,10 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
onPreparedListener = { onPreparedListener = {
runOnUiThread { runOnUiThread {
loadAvailableSubtitles() loadAvailableSubtitles()
startUIUpdateLoop()
if (allSubtitleTracks.size > 1) { if (allSubtitleTracks.size > 1) {
showSubtitleSelectionDialog() showSubtitleSelectionDialog()
} else { } else {
// 자막 없으면 자동 시작 play()
play(Surface(videoTextureView.surfaceTexture!!))
} }
} }
} }
@ -231,30 +274,32 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
tvTime.text = "${formatTime(currentPos)} / ${formatTime(duration)}" 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 갱신 delay(500) // 0.5초마다 UI 갱신
} }
} }
} }
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) { override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
val videoFile = File(videoPath) prepareEngine()
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() { private fun loadAvailableSubtitles() {
@ -276,11 +321,16 @@ class PlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
.setCancelable(false) .setCancelable(false)
.setItems(trackNames) { _, which -> .setItems(trackNames) { _, which ->
selectSubtitleTrack(allSubtitleTracks[which]) selectSubtitleTrack(allSubtitleTracks[which])
nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!)) play()
} }
.show() .show()
} }
fun play() {
startUIUpdateLoop()
nativePlayer?.play(Surface(videoTextureView.surfaceTexture!!))
}
private fun selectSubtitleTrack(track: SubtitleTrack) { private fun selectSubtitleTrack(track: SubtitleTrack) {
if (track.isExternal) { if (track.isExternal) {
nativePlayer?.setInternalSubtitleTrack(-1) nativePlayer?.setInternalSubtitleTrack(-1)