feat: 暂停显示选集、亮度音量手势调节、播放进度续播

This commit is contained in:
xiaji
2026-05-27 22:33:46 +08:00
parent 98e96f3438
commit eb72b061e6
7 changed files with 269 additions and 15 deletions

View File

@@ -1,8 +1,11 @@
package com.videoapp.tv package com.videoapp.tv
import android.content.Context
import android.media.AudioManager
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.MotionEvent
import android.view.View import android.view.View
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
@@ -10,10 +13,12 @@ import android.widget.Button
import android.widget.HorizontalScrollView import android.widget.HorizontalScrollView
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.videoapp.tv.data.AppDatabase import com.videoapp.tv.data.AppDatabase
@@ -33,29 +38,49 @@ class PlayerActivity : AppCompatActivity() {
private lateinit var episodeList: LinearLayout private lateinit var episodeList: LinearLayout
private lateinit var episodeScroll: HorizontalScrollView private lateinit var episodeScroll: HorizontalScrollView
private lateinit var loadingIndicator: View private lateinit var loadingIndicator: View
private lateinit var errorText: android.widget.TextView private lateinit var errorText: TextView
private lateinit var btnClose: ImageButton private lateinit var btnClose: ImageButton
private lateinit var brightnessVolumeIndicator: TextView
private var exoPlayer: ExoPlayer? = null private var exoPlayer: ExoPlayer? = null
private val videoExtractor = VideoExtractor() private val videoExtractor = VideoExtractor()
private val configRepo by lazy { ConfigRepository(this) } private val configRepo by lazy { ConfigRepository(this) }
private val playHistoryDao by lazy { AppDatabase.getInstance(this).playHistoryDao() } private val playHistoryDao by lazy { AppDatabase.getInstance(this).playHistoryDao() }
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
private var sources: List<PlaySource> = emptyList() private var sources: List<PlaySource> = emptyList()
private var currentSourceIndex = 0 private var currentSourceIndex = 0
private var currentEpisode: Episode? = null private var currentEpisode: Episode? = null
private var historyEpisode: String? = null private var historyEpisode: String? = null
private var resumePosition: Long? = null
private var currentPlayHistoryId: Long? = null
private val hideHandler = Handler(Looper.getMainLooper()) private val hideHandler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideControls() } private val hideRunnable = Runnable { hideControls() }
private var controlsVisible = true private var controlsVisible = true
// 用于保存播放历史的信息 private val positionSaveHandler = Handler(Looper.getMainLooper())
private val positionSaveRunnable = object : Runnable {
override fun run() {
saveCurrentPosition()
positionSaveHandler.postDelayed(this, 5000)
}
}
private var videoTitle: String = "" private var videoTitle: String = ""
private var videoCategory: String? = null private var videoCategory: String? = null
private var coverUrl: String? = null private var coverUrl: String? = null
private var detailUrl: String = "" private var detailUrl: String = ""
private var touchStartY = 0f
private var touchStartX = 0f
private var isAdjusting = false
private var isBrightnessMode = false
private var startBrightness = 0f
private var startVolume = 0
private var maxVolume = 0
private val indicatorHideHandler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player) setContentView(R.layout.activity_player)
@@ -69,12 +94,16 @@ class PlayerActivity : AppCompatActivity() {
loadingIndicator = findViewById(R.id.loading_indicator) loadingIndicator = findViewById(R.id.loading_indicator)
errorText = findViewById(R.id.error_text) errorText = findViewById(R.id.error_text)
btnClose = findViewById(R.id.btn_close) btnClose = findViewById(R.id.btn_close)
brightnessVolumeIndicator = findViewById(R.id.brightness_volume_indicator)
detailUrl = intent.getStringExtra("detail_url") ?: "" detailUrl = intent.getStringExtra("detail_url") ?: ""
videoTitle = intent.getStringExtra("title") ?: "" videoTitle = intent.getStringExtra("title") ?: ""
videoCategory = intent.getStringExtra("category") videoCategory = intent.getStringExtra("category")
coverUrl = intent.getStringExtra("cover_url") coverUrl = intent.getStringExtra("cover_url")
historyEpisode = intent.getStringExtra("history_episode") historyEpisode = intent.getStringExtra("history_episode")
resumePosition = if (intent.hasExtra("resume_position")) intent.getLongExtra("resume_position", 0) else null
maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
btnClose.setOnClickListener { finish() } btnClose.setOnClickListener { finish() }
@@ -86,6 +115,16 @@ class PlayerActivity : AppCompatActivity() {
private fun initExoPlayer() { private fun initExoPlayer() {
exoPlayer = ExoPlayer.Builder(this).build().also { player -> exoPlayer = ExoPlayer.Builder(this).build().also { player ->
playerView.player = player playerView.player = player
player.addListener(object : Player.Listener {
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (playWhenReady) {
hideHandler.postDelayed(hideRunnable, 4000)
} else {
hideHandler.removeCallbacks(hideRunnable)
showControls()
}
}
})
} }
} }
@@ -193,7 +232,6 @@ class PlayerActivity : AppCompatActivity() {
showLoading(true) showLoading(true)
val config = configRepo.getConfig() val config = configRepo.getConfig()
// 保存播放历史
savePlayHistory(ep.title) savePlayHistory(ep.title)
lifecycleScope.launch { lifecycleScope.launch {
@@ -212,13 +250,11 @@ class PlayerActivity : AppCompatActivity() {
private fun savePlayHistory(episodeName: String?) { private fun savePlayHistory(episodeName: String?) {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
// 检查是否已存在相同的播放记录
val existing = playHistoryDao.findByDetailUrl(detailUrl) val existing = playHistoryDao.findByDetailUrl(detailUrl)
if (existing != null) { if (existing != null) {
// 更新现有记录的播放时间和剧集
playHistoryDao.updatePlayTime(existing.id, System.currentTimeMillis(), episodeName) playHistoryDao.updatePlayTime(existing.id, System.currentTimeMillis(), episodeName)
currentPlayHistoryId = existing.id
} else { } else {
// 插入新记录
val playHistory = PlayHistory( val playHistory = PlayHistory(
title = videoTitle, title = videoTitle,
episodeName = episodeName, episodeName = episodeName,
@@ -227,19 +263,43 @@ class PlayerActivity : AppCompatActivity() {
category = videoCategory, category = videoCategory,
playTime = System.currentTimeMillis() playTime = System.currentTimeMillis()
) )
playHistoryDao.insert(playHistory) val id = playHistoryDao.insert(playHistory)
currentPlayHistoryId = id
} }
} catch (e: Exception) { } catch (e: Exception) {
// 保存播放历史失败不影响播放功能
e.printStackTrace() e.printStackTrace()
} }
} }
} }
private fun saveCurrentPosition() {
val player = exoPlayer ?: return
val historyId = currentPlayHistoryId ?: return
if (playerView.visibility != View.VISIBLE) return
val pos = player.currentPosition
if (pos > 0) {
lifecycleScope.launch {
try {
playHistoryDao.updatePosition(historyId, pos)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
private fun startPositionSaving() {
positionSaveHandler.removeCallbacks(positionSaveRunnable)
positionSaveHandler.postDelayed(positionSaveRunnable, 5000)
}
private fun stopPositionSaving() {
positionSaveHandler.removeCallbacks(positionSaveRunnable)
}
private fun tryPlayDirectly(detailUrl: String, config: com.videoapp.tv.data.SiteConfig) { private fun tryPlayDirectly(detailUrl: String, config: com.videoapp.tv.data.SiteConfig) {
// 保存播放历史(直接播放时使用标题作为剧集名)
savePlayHistory(videoTitle) savePlayHistory(videoTitle)
lifecycleScope.launch { lifecycleScope.launch {
val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config) val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config)
if (directUrl != null) { if (directUrl != null) {
@@ -265,10 +325,17 @@ class PlayerActivity : AppCompatActivity() {
setMediaItem(mediaItem) setMediaItem(mediaItem)
prepare() prepare()
playWhenReady = true playWhenReady = true
if (resumePosition != null && resumePosition!! > 0) {
seekTo(resumePosition!!)
resumePosition = null
}
} }
startPositionSaving()
} }
private fun playWithWebView(url: String) { private fun playWithWebView(url: String) {
stopPositionSaving()
playerView.visibility = View.GONE playerView.visibility = View.GONE
playerWebView.visibility = View.VISIBLE playerWebView.visibility = View.VISIBLE
showLoading(false) showLoading(false)
@@ -302,9 +369,60 @@ class PlayerActivity : AppCompatActivity() {
} }
private fun setupTouchListeners() { private fun setupTouchListeners() {
val listener = View.OnClickListener { toggleControls() } val touchListener = View.OnTouchListener { view, event ->
playerView.setOnClickListener(listener) when (event.action) {
playerWebView.setOnClickListener(listener) MotionEvent.ACTION_DOWN -> {
touchStartX = event.x
touchStartY = event.y
isAdjusting = false
val halfWidth = view.width / 2
isBrightnessMode = touchStartX < halfWidth
startBrightness = window.attributes.screenBrightness
if (startBrightness < 0) startBrightness = 0.5f
startVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
true
}
MotionEvent.ACTION_MOVE -> {
val deltaY = touchStartY - event.y
if (kotlin.math.abs(event.y - touchStartY) > 10f) {
isAdjusting = true
}
if (isAdjusting) {
if (isBrightnessMode) {
val newBrightness = (startBrightness + deltaY / view.height).coerceIn(0.01f, 1.0f)
window.attributes.screenBrightness = newBrightness
window.attributes = window.attributes
showIndicator("亮度", (newBrightness * 100).toInt())
} else {
val range = maxVolume.toFloat()
val newVolume = (startVolume + (deltaY / view.height) * range).toInt().coerceIn(0, maxVolume)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
showIndicator("音量", (newVolume * 100 / maxVolume))
}
}
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val movedFar = kotlin.math.abs(event.x - touchStartX) > 20f || kotlin.math.abs(event.y - touchStartY) > 20f
if (!isAdjusting && !movedFar) {
toggleControls()
}
true
}
else -> false
}
}
playerView.setOnTouchListener(touchListener)
playerWebView.setOnTouchListener(touchListener)
}
private fun showIndicator(label: String, percent: Int) {
brightnessVolumeIndicator.text = "$label: $percent%"
brightnessVolumeIndicator.visibility = View.VISIBLE
indicatorHideHandler.removeCallbacksAndMessages(null)
indicatorHideHandler.postDelayed({
brightnessVolumeIndicator.visibility = View.GONE
}, 1500)
} }
private fun toggleControls() { private fun toggleControls() {
@@ -338,11 +456,15 @@ class PlayerActivity : AppCompatActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
saveCurrentPosition()
stopPositionSaving()
exoPlayer?.playWhenReady = false exoPlayer?.playWhenReady = false
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
saveCurrentPosition()
stopPositionSaving()
hideHandler.removeCallbacks(hideRunnable) hideHandler.removeCallbacks(hideRunnable)
exoPlayer?.release() exoPlayer?.release()
exoPlayer = null exoPlayer = null

View File

@@ -11,5 +11,6 @@ data class PlayHistory(
val detailUrl: String, val detailUrl: String,
val coverUrl: String? = null, val coverUrl: String? = null,
val category: String? = null, val category: String? = null,
val playTime: Long = System.currentTimeMillis() val playTime: Long = System.currentTimeMillis(),
val playbackPosition: Long? = null
) )

View File

@@ -13,7 +13,7 @@ interface PlayHistoryDao {
fun getRecentPlayHistory(): Flow<List<PlayHistory>> fun getRecentPlayHistory(): Flow<List<PlayHistory>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(playHistory: PlayHistory) suspend fun insert(playHistory: PlayHistory): Long
@Query("DELETE FROM play_history WHERE detailUrl = :detailUrl") @Query("DELETE FROM play_history WHERE detailUrl = :detailUrl")
suspend fun deleteByDetailUrl(detailUrl: String) suspend fun deleteByDetailUrl(detailUrl: String)
@@ -26,4 +26,7 @@ interface PlayHistoryDao {
@Query("UPDATE play_history SET playTime = :playTime, episodeName = :episodeName WHERE id = :id") @Query("UPDATE play_history SET playTime = :playTime, episodeName = :episodeName WHERE id = :id")
suspend fun updatePlayTime(id: Long, playTime: Long, episodeName: String?) suspend fun updatePlayTime(id: Long, playTime: Long, episodeName: String?)
@Query("UPDATE play_history SET playbackPosition = :position WHERE id = :id")
suspend fun updatePosition(id: Long, position: Long)
} }

View File

@@ -226,6 +226,9 @@ class SearchFragment : Fragment() {
putExtra("category", playHistory.category) putExtra("category", playHistory.category)
putExtra("cover_url", playHistory.coverUrl) putExtra("cover_url", playHistory.coverUrl)
putExtra("history_episode", playHistory.episodeName) putExtra("history_episode", playHistory.episodeName)
if (playHistory.playbackPosition != null && playHistory.playbackPosition > 0) {
putExtra("resume_position", playHistory.playbackPosition)
}
} }
startActivity(intent) startActivity(intent)
} }

View File

@@ -89,4 +89,15 @@
android:textSize="16sp" android:textSize="16sp"
android:visibility="gone" /> android:visibility="gone" />
<TextView
android:id="@+id/brightness_volume_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="#CC000000"
android:padding="16dp"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:visibility="gone" />
</FrameLayout> </FrameLayout>

View File

@@ -0,0 +1,85 @@
# Player Enhancements Implementation Plan
> **Goal:** Add pause-to-show-controls, brightness/volume gestures, and playback position resume to PlayerActivity.
**Architecture:** Touch gesture detection on playerView, ExoPlayer listener for pause state, PlayHistory entity extension for position, AudioManager for volume, Window attributes for brightness.
**Tech Stack:** Kotlin, Android, ExoPlayer Media3, Room, AudioManager
---
### Task 1: Add playbackPosition to PlayHistory entity and DAO
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/data/PlayHistory.kt`
- Modify: `app/src/main/java/com/videoapp/tv/data/PlayHistoryDao.kt`
- [ ] **Step 1: Add playbackPosition field to PlayHistory entity**
Add `val playbackPosition: Long? = null` to the data class. Since the DB is `fallbackToDestructiveMigration()`, no migration needed.
- [ ] **Step 2: Add updatePosition method to PlayHistoryDao**
```kotlin
@Query("UPDATE play_history SET playbackPosition = :position WHERE id = :id")
suspend fun updatePosition(id: Long, position: Long)
```
### Task 2: Save and resume playback position in PlayerActivity
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/PlayerActivity.kt`
- Modify: `app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt`
- [ ] **Step 1: Add fields and read resume position**
Add `resumePosition: Long?` field, read from intent `"resume_position"`.
- [ ] **Step 2: Modify playWithExoPlayer to seek to saved position**
After `prepare()`, add a listener to seek to `resumePosition` when ready, then clear `resumePosition`.
- [ ] **Step 3: Add periodic position saving**
Add a `positionSaveHandler` and `positionSaveRunnable` that saves current position every 5 seconds.
- [ ] **Step 4: Save position on pause/destroy**
In `onPause` and `onDestroy`, save current playback position before releasing player.
- [ ] **Step 5: Pass playbackPosition from SearchFragment**
In `openPlayerFromHistory`, pass `playHistory.playbackPosition` as `"resume_position"`.
### Task 3: Pause shows controls
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/PlayerActivity.kt`
- [ ] **Step 1: Add ExoPlayer Listener for playWhenReady changes**
In `initExoPlayer`, add a `Player.Listener` that calls `showControls()` on pause and restarts auto-hide on play.
### Task 4: Brightness/Volume touch gestures
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/PlayerActivity.kt`
- Modify: `app/src/main/res/layout/activity_player.xml`
- [ ] **Step 1: Replace click listener with touch listener on playerView**
Replace `OnClickListener` with `OnTouchListener`. Track `startY`, `isAdjusting`, `isBrightnessMode`, `startBrightness`, `startVolume`.
- [ ] **Step 2: Handle ACTION_DOWN / MOVE / UP**
- DOWN: record startY, check x < width/2 for brightness mode
- MOVE: calculate delta, adjust brightness or volume, show indicator
- UP: if delta < threshold (20px), treat as click → toggleControls
- [ ] **Step 3: Add floating indicator overlay TextView in layout**
Add a `brightness_volume_indicator` TextView to activity_player.xml, centered, initially GONE.
- [ ] **Step 4: Implement showIndicator method**
Show the indicator with current brightness/volume percentage for 1 second after adjustment stops.

View File

@@ -0,0 +1,29 @@
# Player Enhancements Design
## 1. Pause Shows Controls
- Add `Player.Listener` to ExoPlayer, monitor `onPlayWhenReadyChanged`
- When `playWhenReady` becomes false: show control panel, cancel auto-hide timer
- When `playWhenReady` becomes true: restart auto-hide timer
- WebView playback: not supported (can't detect pause)
## 2. Brightness/Volume Gestures
- Replace click listener with `OnTouchListener` on playerView/playerWebView
- `ACTION_DOWN`: record startY, determine left/right half of screen
- Left half: brightness mode
- Right half: volume mode
- `ACTION_MOVE`: calculate deltaY, adjust brightness/volume proportionally
- `ACTION_UP`: if delta < threshold, treat as click (toggle controls); reset state
- Brightness: `window.attributes.screenBrightness` (activity-scoped, no permission)
- Volume: `AudioManager.setStreamVolume(STREAM_MUSIC, ...)`
- Show floating indicator (TextView overlay) during adjustment
## 3. Playback Position Resume
- Add `playbackPosition: Long?` to `PlayHistory` entity
- Add `updatePosition(id, position)` to `PlayHistoryDao`
- Save position every 5 seconds via handler, and on pause/destroy
- On player entry: read position from history, seek to it after prepare
- Pass `resume_position` via Intent from SearchFragment
- ExoPlayer only (WebView excluded)