playing in background

This commit is contained in:
2026-10-24 13:04:57 +08:00
parent 532d9900ba
commit 38894ba80c
8 changed files with 136 additions and 2 deletions

View File

@@ -1,3 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
</manifest>

View File

@@ -69,6 +69,7 @@ class OralTrainerController internal constructor(
)
player = ExoPlayer.Builder(appContext)
.setMediaSourceFactory(mediaSourceFactory)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
.also { exoPlayer ->
exoPlayer.repeatMode = Player.REPEAT_MODE_ALL

View File

@@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -19,6 +22,11 @@
</intent-filter>
</activity>
<service
android:name=".TrainingPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@@ -44,11 +44,16 @@ class MainActivity : Activity() {
private var translationRequest: CancellableTranslationRequest? = null
private var progressBarDragging = false
private var trainingPlaybackServiceActive = false
private val screenActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_SCREEN_OFF) {
testSession.onScreenOff()
// Training playback intentionally keeps running with the screen off.
// The test module still pauses because recording/assessment state is UI-dependent.
if (screenState.activeModule == Module.TEST) {
testSession.onScreenOff()
}
}
}
}
@@ -149,8 +154,9 @@ class MainActivity : Activity() {
if (bindings.hasPlayerChrome) {
bindings.statusText.text = "已停止配音播放"
}
} else {
} else if (screenState.activeModule != Module.TRAIN) {
controller.pause()
stopTrainingPlaybackService()
}
super.onPause()
}
@@ -178,6 +184,7 @@ class MainActivity : Activity() {
override fun onDestroy() {
authCoordinator.reset()
testSession.release()
stopTrainingPlaybackService()
controller.release()
explanationOverlay.release()
translationRequest?.cancel()
@@ -221,6 +228,7 @@ class MainActivity : Activity() {
sdk.config.userToken = null
screenState.clearCourse()
controller.pause()
stopTrainingPlaybackService()
screenState.activeModule = Module.COURSE
setRootView()
}
@@ -230,6 +238,10 @@ class MainActivity : Activity() {
testSession.stopDubbingIfNeeded()
}
screenState.activeModule = module
if (module != Module.TRAIN) {
controller.pause()
stopTrainingPlaybackService()
}
if (module == Module.COURSE || module == Module.MINE) {
catalogController.loadUserData(module) {
screenFactory.refreshMyContent(sdk.userApi, authSessionStore.session?.token.orEmpty())
@@ -305,6 +317,11 @@ class MainActivity : Activity() {
private fun applyPlaybackSnapshot(snapshot: PlaybackSnapshot) {
if (!bindings.hasPlayerChrome) return
if (screenState.activeModule == Module.TRAIN && snapshot.isPlaying) {
startTrainingPlaybackService()
} else if (!snapshot.isPlaying) {
stopTrainingPlaybackService()
}
if (!progressBarDragging) {
bindings.timeText.text =
"${PlaybackFormat.time(snapshot.positionMs)} / ${PlaybackFormat.time(snapshot.durationMs)}"
@@ -314,6 +331,18 @@ class MainActivity : Activity() {
bindings.statusText.text = PlaybackFormat.status(snapshot)
}
private fun startTrainingPlaybackService() {
if (trainingPlaybackServiceActive) return
TrainingPlaybackService.start(this)
trainingPlaybackServiceActive = true
}
private fun stopTrainingPlaybackService() {
if (!trainingPlaybackServiceActive) return
TrainingPlaybackService.stop(this)
trainingPlaybackServiceActive = false
}
private fun applySentence(sentence: SentenceBoundary?) {
if (!bindings.hasPlayerChrome) return
if (sentence == null) {

View File

@@ -0,0 +1,79 @@
package cn.learningpad.oraltrainer.sample
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
/** Keeps the training player alive after the screen turns off or the app backgrounds. */
class TrainingPlaybackService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.training_playback_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
return START_NOT_STICKY
}
private fun buildNotification(): Notification {
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.training_playback_notification_title))
.setContentText(getString(R.string.training_playback_notification_text))
.setSmallIcon(R.drawable.ic_notification_play)
.setContentIntent(contentIntent)
.setOngoing(true)
.setCategory(Notification.CATEGORY_TRANSPORT)
.build()
}
companion object {
private const val CHANNEL_ID = "training_playback"
private const val NOTIFICATION_ID = 1001
fun start(context: Context) {
val intent = Intent(context, TrainingPlaybackService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, TrainingPlaybackService::class.java))
}
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8,5v14l11,-7z" />
</vector>

View File

@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">八哥口语</string>
<string name="training_playback_channel_name">训练播放</string>
<string name="training_playback_notification_title">口语训练</string>
<string name="training_playback_notification_text">正在后台播放</string>
</resources>

View File

@@ -1,3 +1,6 @@
<resources>
<string name="app_name">MynaTalk</string>
<string name="training_playback_channel_name">Training playback</string>
<string name="training_playback_notification_title">Oral training</string>
<string name="training_playback_notification_text">Playing in the background</string>
</resources>