diff --git a/app/src/main/java/com/videoapp/tv/PlayerActivity.kt b/app/src/main/java/com/videoapp/tv/PlayerActivity.kt index 451f259..3808166 100644 --- a/app/src/main/java/com/videoapp/tv/PlayerActivity.kt +++ b/app/src/main/java/com/videoapp/tv/PlayerActivity.kt @@ -1,8 +1,11 @@ package com.videoapp.tv +import android.content.Context +import android.media.AudioManager import android.os.Bundle import android.os.Handler import android.os.Looper +import android.view.MotionEvent import android.view.View import android.webkit.WebView import android.webkit.WebViewClient @@ -10,10 +13,12 @@ import android.widget.Button import android.widget.HorizontalScrollView import android.widget.ImageButton import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import com.videoapp.tv.data.AppDatabase @@ -33,29 +38,49 @@ class PlayerActivity : AppCompatActivity() { private lateinit var episodeList: LinearLayout private lateinit var episodeScroll: HorizontalScrollView 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 brightnessVolumeIndicator: TextView private var exoPlayer: ExoPlayer? = null private val videoExtractor = VideoExtractor() private val configRepo by lazy { ConfigRepository(this) } private val playHistoryDao by lazy { AppDatabase.getInstance(this).playHistoryDao() } + private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager } private var sources: List = emptyList() private var currentSourceIndex = 0 private var currentEpisode: Episode? = null private var historyEpisode: String? = null + private var resumePosition: Long? = null + private var currentPlayHistoryId: Long? = null private val hideHandler = Handler(Looper.getMainLooper()) private val hideRunnable = Runnable { hideControls() } 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 videoCategory: String? = null private var coverUrl: String? = null 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?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_player) @@ -69,12 +94,16 @@ class PlayerActivity : AppCompatActivity() { loadingIndicator = findViewById(R.id.loading_indicator) errorText = findViewById(R.id.error_text) btnClose = findViewById(R.id.btn_close) + brightnessVolumeIndicator = findViewById(R.id.brightness_volume_indicator) detailUrl = intent.getStringExtra("detail_url") ?: "" videoTitle = intent.getStringExtra("title") ?: "" videoCategory = intent.getStringExtra("category") coverUrl = intent.getStringExtra("cover_url") 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() } @@ -86,6 +115,16 @@ class PlayerActivity : AppCompatActivity() { private fun initExoPlayer() { exoPlayer = ExoPlayer.Builder(this).build().also { 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) val config = configRepo.getConfig() - // 保存播放历史 savePlayHistory(ep.title) lifecycleScope.launch { @@ -212,13 +250,11 @@ class PlayerActivity : AppCompatActivity() { private fun savePlayHistory(episodeName: String?) { lifecycleScope.launch { try { - // 检查是否已存在相同的播放记录 val existing = playHistoryDao.findByDetailUrl(detailUrl) if (existing != null) { - // 更新现有记录的播放时间和剧集 playHistoryDao.updatePlayTime(existing.id, System.currentTimeMillis(), episodeName) + currentPlayHistoryId = existing.id } else { - // 插入新记录 val playHistory = PlayHistory( title = videoTitle, episodeName = episodeName, @@ -227,19 +263,43 @@ class PlayerActivity : AppCompatActivity() { category = videoCategory, playTime = System.currentTimeMillis() ) - playHistoryDao.insert(playHistory) + val id = playHistoryDao.insert(playHistory) + currentPlayHistoryId = id } } catch (e: Exception) { - // 保存播放历史失败不影响播放功能 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) { - // 保存播放历史(直接播放时使用标题作为剧集名) savePlayHistory(videoTitle) - + lifecycleScope.launch { val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config) if (directUrl != null) { @@ -265,10 +325,17 @@ class PlayerActivity : AppCompatActivity() { setMediaItem(mediaItem) prepare() playWhenReady = true + + if (resumePosition != null && resumePosition!! > 0) { + seekTo(resumePosition!!) + resumePosition = null + } } + startPositionSaving() } private fun playWithWebView(url: String) { + stopPositionSaving() playerView.visibility = View.GONE playerWebView.visibility = View.VISIBLE showLoading(false) @@ -302,9 +369,60 @@ class PlayerActivity : AppCompatActivity() { } private fun setupTouchListeners() { - val listener = View.OnClickListener { toggleControls() } - playerView.setOnClickListener(listener) - playerWebView.setOnClickListener(listener) + val touchListener = View.OnTouchListener { view, event -> + when (event.action) { + 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() { @@ -338,11 +456,15 @@ class PlayerActivity : AppCompatActivity() { override fun onPause() { super.onPause() + saveCurrentPosition() + stopPositionSaving() exoPlayer?.playWhenReady = false } override fun onDestroy() { super.onDestroy() + saveCurrentPosition() + stopPositionSaving() hideHandler.removeCallbacks(hideRunnable) exoPlayer?.release() exoPlayer = null diff --git a/app/src/main/java/com/videoapp/tv/data/PlayHistory.kt b/app/src/main/java/com/videoapp/tv/data/PlayHistory.kt index 82d3875..adc1494 100644 --- a/app/src/main/java/com/videoapp/tv/data/PlayHistory.kt +++ b/app/src/main/java/com/videoapp/tv/data/PlayHistory.kt @@ -11,5 +11,6 @@ data class PlayHistory( val detailUrl: String, val coverUrl: String? = null, val category: String? = null, - val playTime: Long = System.currentTimeMillis() + val playTime: Long = System.currentTimeMillis(), + val playbackPosition: Long? = null ) diff --git a/app/src/main/java/com/videoapp/tv/data/PlayHistoryDao.kt b/app/src/main/java/com/videoapp/tv/data/PlayHistoryDao.kt index 373bcaa..535c887 100644 --- a/app/src/main/java/com/videoapp/tv/data/PlayHistoryDao.kt +++ b/app/src/main/java/com/videoapp/tv/data/PlayHistoryDao.kt @@ -13,7 +13,7 @@ interface PlayHistoryDao { fun getRecentPlayHistory(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(playHistory: PlayHistory) + suspend fun insert(playHistory: PlayHistory): Long @Query("DELETE FROM play_history WHERE detailUrl = :detailUrl") suspend fun deleteByDetailUrl(detailUrl: String) @@ -26,4 +26,7 @@ interface PlayHistoryDao { @Query("UPDATE play_history SET playTime = :playTime, episodeName = :episodeName WHERE id = :id") 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) } diff --git a/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt b/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt index a9fa48d..b246245 100644 --- a/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt +++ b/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt @@ -226,6 +226,9 @@ class SearchFragment : Fragment() { putExtra("category", playHistory.category) putExtra("cover_url", playHistory.coverUrl) putExtra("history_episode", playHistory.episodeName) + if (playHistory.playbackPosition != null && playHistory.playbackPosition > 0) { + putExtra("resume_position", playHistory.playbackPosition) + } } startActivity(intent) } diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 725bb50..e17ebd7 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -89,4 +89,15 @@ android:textSize="16sp" android:visibility="gone" /> + + diff --git a/docs/superpowers/plans/2026-05-27-player-enhancements.md b/docs/superpowers/plans/2026-05-27-player-enhancements.md new file mode 100644 index 0000000..b286a29 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-player-enhancements.md @@ -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. diff --git a/docs/superpowers/specs/2026-05-27-player-enhancements-design.md b/docs/superpowers/specs/2026-05-27-player-enhancements-design.md new file mode 100644 index 0000000..9b6e7b0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-player-enhancements-design.md @@ -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)