This commit is contained in:
2026-04-09 17:16:23 +09:00
parent 5e7c121cf8
commit 1da8d5bb62
11 changed files with 800 additions and 24 deletions
@@ -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) // 이미지나 문서는 기존처럼
}
}
}
},
@@ -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()
@@ -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? {
@@ -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") }
}
}
@@ -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()
}
}