diff --git a/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt b/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt index 7e1adcaedc48..2c40303f9bcb 100644 --- a/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt +++ b/app/src/main/java/com/nextcloud/client/media/BackgroundPlayerService.kt @@ -1,6 +1,7 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2024 Parneet Singh * SPDX-License-Identifier: AGPL-3.0-or-later */ @@ -12,8 +13,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.os.Build import android.os.Bundle import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat import androidx.media3.common.Player import androidx.media3.common.Player.COMMAND_PLAY_PAUSE import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT @@ -41,9 +46,15 @@ import com.nextcloud.client.network.ClientFactory import com.nextcloud.common.NextcloudClient import com.nextcloud.utils.extensions.registerBroadcastReceiver import com.owncloud.android.MainApp +import com.owncloud.android.R import com.owncloud.android.datamodel.ReceiverFlag +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.notifications.NotificationUtils +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -52,45 +63,86 @@ class BackgroundPlayerService : MediaSessionService(), Injectable { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val seekBackSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_BACK, Bundle.EMPTY) private val seekForwardSessionCommand = SessionCommand(SESSION_COMMAND_ACTION_SEEK_FORWARD, Bundle.EMPTY) - val seekForward = - CommandButton.Builder() - .setDisplayName("Seek Forward") - .setIconResId(CommandButton.getIconResIdForIconConstant(CommandButton.ICON_SKIP_FORWARD_15)) - .setSessionCommand(seekForwardSessionCommand) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 2) }) - .build() - - val seekBackward = - CommandButton.Builder() - .setDisplayName("Seek Backward") - .setIconResId(CommandButton.getIconResIdForIconConstant(CommandButton.ICON_SKIP_BACK_5)) - .setSessionCommand(seekBackSessionCommand) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 0) }) - .build() + private lateinit var seekForward: CommandButton + private lateinit var seekBackward: CommandButton @Inject lateinit var clientFactory: ClientFactory @Inject lateinit var userAccountManager: UserAccountManager - lateinit var exoPlayer: ExoPlayer + + private lateinit var exoPlayer: ExoPlayer private var mediaSession: MediaSession? = null + private var isPlayerReady = false private val stopReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { RELEASE_MEDIA_SESSION_BROADCAST_ACTION -> release() - STOP_MEDIA_SESSION_BROADCAST_ACTION -> exoPlayer.stop() + + STOP_MEDIA_SESSION_BROADCAST_ACTION -> { + if (isPlayerReady) { + exoPlayer.stop() + } else { + stopSelf() + } + } } } } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = NotificationCompat.Builder(this, NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) + .setSmallIcon(R.drawable.logo) + .setContentTitle(getString(R.string.media_player_playing)) + .setSilent(true) + .build() + + ServiceCompat.startForeground( + this, + DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } else { + 0 + } + ) + + return super.onStartCommand(intent, flags, startId) + } + + @Suppress("DEPRECATION") override fun onCreate() { super.onCreate() + MainApp.getAppComponent().inject(this) + + seekForward = CommandButton.Builder() + .setDisplayName(getString(R.string.media_player_seek_forward)) + .setIconResId(R.drawable.ic_skip_next) + .setSessionCommand(seekForwardSessionCommand) + .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 2) }) + .build() + + seekBackward = CommandButton.Builder() + .setDisplayName(getString(R.string.media_player_seek_backward)) + .setIconResId(R.drawable.ic_skip_previous) + .setSessionCommand(seekBackSessionCommand) + .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 0) }) + .build() + + exoPlayer = ExoPlayer.Builder(this).build() + mediaSession = buildMediaSession(exoPlayer) + + setMediaNotificationProvider(buildNotificationProvider()) + registerBroadcastReceiver( stopReceiver, IntentFilter().apply { @@ -100,100 +152,101 @@ class BackgroundPlayerService : ReceiverFlag.NotExported ) - MainApp.getAppComponent().inject(this) - initNextcloudExoPlayer() + initExoPlayer() + } - setMediaNotificationProvider(object : DefaultMediaNotificationProvider(this) { - override fun getMediaButtons( - session: MediaSession, - playerCommands: Player.Commands, - customLayout: ImmutableList, - showPauseButton: Boolean - ): ImmutableList { - val playPauseButton = - CommandButton.Builder() - .setDisplayName("PlayPause") - .setIconResId( - CommandButton.getIconResIdForIconConstant( - if (mediaSession?.player?.isPlaying == true) { - CommandButton.ICON_PAUSE - } else { - CommandButton.ICON_PLAY - } - ) - ) - .setPlayerCommand(COMMAND_PLAY_PAUSE) - .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 1) }) - .build() - - val myCustomButtonsLayout = - ImmutableList.of(seekBackward, playPauseButton, seekForward) - return myCustomButtonsLayout + @Suppress("TooGenericExceptionCaught") + private fun initExoPlayer() { + serviceScope.launch { + try { + val nextcloudClient: NextcloudClient = withContext(Dispatchers.IO) { + clientFactory.createNextcloudClient(userAccountManager.user) + } + + val realPlayer = createNextcloudExoplayer(this@BackgroundPlayerService, nextcloudClient) + exoPlayer.release() + exoPlayer = realPlayer + isPlayerReady = true + + // Update the session to use the real player + mediaSession?.player = realPlayer + } catch (e: Exception) { + Log_OC.e(TAG, "Failed to initialise Nextcloud ExoPlayer: ${e.message}") + stopSelf() } - }) + } } - private fun initNextcloudExoPlayer() { - runBlocking { - var nextcloudClient: NextcloudClient - withContext(Dispatchers.IO) { - nextcloudClient = clientFactory.createNextcloudClient(userAccountManager.user) + private fun buildMediaSession(player: ExoPlayer): MediaSession = MediaSession.Builder(applicationContext, player) + .setId(BACKGROUND_MEDIA_SESSION_ID) + .setCustomLayout(listOf(seekBackward, seekForward)) + .setCallback(object : MediaSession.Callback { + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): ConnectionResult = + AcceptedResultBuilder(mediaSession ?: session) + .setAvailablePlayerCommands( + ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() + .remove(COMMAND_SEEK_TO_NEXT) + .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .remove(COMMAND_SEEK_TO_PREVIOUS) + .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build() + ) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .addSessionCommands( + listOf(seekBackSessionCommand, seekForwardSessionCommand) + ).build() + ) + .build() + + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { + session.setCustomLayout(listOf(seekBackward, seekForward)) } - nextcloudClient.let { - exoPlayer = createNextcloudExoplayer(this@BackgroundPlayerService, nextcloudClient) - mediaSession = - MediaSession.Builder(applicationContext, exoPlayer) - // set id to distinct this session to avoid crash - // in case session release delayed a bit and - // we start another session for eg. video - .setId(BACKGROUND_MEDIA_SESSION_ID) - .setCustomLayout(listOf(seekBackward, seekForward)) - .setCallback(object : MediaSession.Callback { - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo - ): ConnectionResult = AcceptedResultBuilder(mediaSession!!) - .setAvailablePlayerCommands( - ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() - .remove(COMMAND_SEEK_TO_NEXT) - .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) - .remove(COMMAND_SEEK_TO_PREVIOUS) - .remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) - .build() - ) - .setAvailableSessionCommands( - ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() - .addSessionCommands( - listOf(seekBackSessionCommand, seekForwardSessionCommand) - ).build() - ) - .build() - - override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { - session.setCustomLayout(listOf(seekBackward, seekForward)) - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = when (customCommand.customAction) { - SESSION_COMMAND_ACTION_SEEK_FORWARD -> { - session.player.seekForward() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - SESSION_COMMAND_ACTION_SEEK_BACK -> { - session.player.seekBack() - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - else -> super.onCustomCommand(session, controller, customCommand, args) - } - }) - .build() + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture = when (customCommand.customAction) { + SESSION_COMMAND_ACTION_SEEK_FORWARD -> { + session.player.seekForward() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + SESSION_COMMAND_ACTION_SEEK_BACK -> { + session.player.seekBack() + Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + else -> super.onCustomCommand(session, controller, customCommand, args) } + }) + .build() + + private fun buildNotificationProvider() = object : DefaultMediaNotificationProvider(this) { + @Suppress("DEPRECATION") + override fun getMediaButtons( + session: MediaSession, + playerCommands: Player.Commands, + customLayout: ImmutableList, + showPauseButton: Boolean + ): ImmutableList { + val isPlaying = mediaSession?.player?.isPlaying == true + val playPauseButton = CommandButton.Builder() + .setDisplayName( + if (isPlaying) { + getString(R.string.media_player_pause) + } else { + getString(R.string.media_player_play) + } + ) + .setIconResId(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow) + .setPlayerCommand(COMMAND_PLAY_PAUSE) + .setExtras(Bundle().apply { putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, 1) }) + .build() + + return ImmutableList.of(seekBackward, playPauseButton, seekForward) } } @@ -203,6 +256,7 @@ class BackgroundPlayerService : override fun onDestroy() { unregisterReceiver(stopReceiver) + serviceScope.cancel() mediaSession?.run { player.release() release() @@ -214,28 +268,27 @@ class BackgroundPlayerService : private fun release() { val player = mediaSession?.player if (player?.playWhenReady == true) { - // Make sure the service is not in foreground. player.pause() } - // Bug in Android 14, https://github.com/androidx/media/issues/805 - // that sometimes onTaskRemove() doesn't get called immediately - // eventually gets called so the service stops but the notification doesn't clear out. - // [WORKAROUND] So, explicitly removing the notification here. - // TODO revisit after bug solved! val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nm.cancel(DefaultMediaNotificationProvider.DEFAULT_NOTIFICATION_ID) + stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } - override fun onGetSession(p0: MediaSession.ControllerInfo): MediaSession? = mediaSession + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession companion object { + private val TAG = BackgroundPlayerService::class.java.simpleName + private const val SESSION_COMMAND_ACTION_SEEK_BACK = "SESSION_COMMAND_ACTION_SEEK_BACK" private const val SESSION_COMMAND_ACTION_SEEK_FORWARD = "SESSION_COMMAND_ACTION_SEEK_FORWARD" + private const val BACKGROUND_MEDIA_SESSION_ID = + "com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID" - private const val BACKGROUND_MEDIA_SESSION_ID = "com.nextcloud.client.media.BACKGROUND_MEDIA_SESSION_ID" - - const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION = "com.nextcloud.client.media.RELEASE_MEDIA_SESSION" - const val STOP_MEDIA_SESSION_BROADCAST_ACTION = "com.nextcloud.client.media.STOP_MEDIA_SESSION" + const val RELEASE_MEDIA_SESSION_BROADCAST_ACTION = + "com.nextcloud.client.media.RELEASE_MEDIA_SESSION" + const val STOP_MEDIA_SESSION_BROADCAST_ACTION = + "com.nextcloud.client.media.STOP_MEDIA_SESSION" } } diff --git a/app/src/main/java/com/nextcloud/client/media/PlayerService.kt b/app/src/main/java/com/nextcloud/client/media/PlayerService.kt index e7c67a7a6ccc..15e6398351c0 100644 --- a/app/src/main/java/com/nextcloud/client/media/PlayerService.kt +++ b/app/src/main/java/com/nextcloud/client/media/PlayerService.kt @@ -64,7 +64,6 @@ class PlayerService : Service() { putExtra(IS_MEDIA_CONTROL_LAYOUT_READY, false) } LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) - startForeground(file) } override fun onStart() { @@ -133,6 +132,19 @@ class PlayerService : Service() { override fun onBind(intent: Intent?): IBinder? = Binder(this) override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + Log_OC.d(TAG, "player service started") + if (!isRunning) { + val file = intent.getParcelableArgument(EXTRA_FILE, OCFile::class.java) + if (file != null) { + startForeground(file) + } else { + startForegroundWithPlaceholder() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + } + when (intent.action) { ACTION_PLAY -> onActionPlay(intent) ACTION_STOP -> onActionStop() @@ -142,6 +154,23 @@ class PlayerService : Service() { return START_NOT_STICKY } + private fun startForegroundWithPlaceholder() { + val ticker = String.format(getString(R.string.media_notif_ticker), getString(R.string.app_name)) + notificationBuilder.run { + setSmallIcon(R.drawable.ic_play_arrow) + setWhen(System.currentTimeMillis()) + setOngoing(false) + setContentTitle(ticker) + setChannelId(NotificationUtils.NOTIFICATION_CHANNEL_MEDIA) + } + ForegroundServiceHelper.startService( + this, + R.string.media_notif_ticker, + notificationBuilder.build(), + ForegroundServiceType.MediaPlayback + ) + } + private fun onActionToggle() { player.run { if (isPlaying) { diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt index 3f68ba5ded0a..cfc9f506b8fe 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewMediaFragment.kt @@ -18,10 +18,7 @@ import android.content.Intent import android.content.res.Configuration import android.content.res.Resources import android.net.Uri -import android.os.AsyncTask import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -37,6 +34,7 @@ import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -58,7 +56,6 @@ import com.nextcloud.ui.fileactions.FileActionsBottomSheet.Companion.newInstance import com.nextcloud.utils.extensions.getParcelableArgument import com.nextcloud.utils.extensions.getTypedActivity import com.nextcloud.utils.extensions.logFileSize -import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.FragmentPreviewMediaBinding import com.owncloud.android.datamodel.OCFile @@ -71,8 +68,9 @@ import com.owncloud.android.ui.dialog.ConfirmationDialogFragment import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment import com.owncloud.android.ui.fragment.FileFragment import com.owncloud.android.utils.MimeTypeUtil -import java.lang.ref.WeakReference -import java.util.concurrent.Executors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -222,27 +220,30 @@ class PreviewMediaFragment : Log_OC.d(TAG, "File is null or fragment not attached to a context.") return } - prepareForVideo(context ?: MainApp.getAppContext()) + prepareForVideo() } @Suppress("DEPRECATION", "TooGenericExceptionCaught") - private fun prepareForVideo(context: Context) { + private fun prepareForVideo() { if (exoPlayer != null) { playVideo() - } else { - val handler = Handler(Looper.getMainLooper()) - Executors.newSingleThreadExecutor().execute { - try { - nextcloudClient = clientFactory.createNextcloudClient(accountManager.user) - handler.post { - nextcloudClient?.let { client -> - createExoPlayer(context, client) - playVideo() - } - } - } catch (e: CreationException) { - handler.post { Log_OC.e(TAG, "error setting up ExoPlayer", e) } + return + } + + lifecycleScope.launch { + try { + val client = withContext(Dispatchers.IO) { + clientFactory.createNextcloudClient(accountManager.user) } + nextcloudClient = client + val ctx = this@PreviewMediaFragment.context ?: return@launch + + withContext(Dispatchers.Main) { + createExoPlayer(ctx, client) + playVideo() + } + } catch (e: CreationException) { + Log_OC.e(TAG, "error setting up ExoPlayer", e) } } } @@ -253,9 +254,8 @@ class PreviewMediaFragment : val listener = ExoplayerListener(context, binding.exoplayerView, it) { goBackToLivePhoto() } it.addListener(listener) } - // session id needs to be unique since this fragment is used in viewpager multiple fragments can exist at a time mediaSession = MediaSession.Builder( - requireContext(), + context, exoPlayer as Player ).setId(System.currentTimeMillis().toString()).build() } @@ -408,21 +408,48 @@ class PreviewMediaFragment : @Suppress("TooGenericExceptionCaught") private fun playVideo() { setupVideoView() - // load the video file in the video player - // when done, VideoHelper#onPrepared() will be called if (file.isDown) { playVideoUri(file.storageUri) - } else { + return + } + + lifecycleScope.launch { try { - LoadStreamUrl(this, user, clientFactory).execute( - file.localId - ) + val uri = withContext(Dispatchers.IO) { + loadStreamUrl(user, clientFactory, file.localId) + } + if (uri != null) { + videoUri = uri + playVideoUri(uri) + } else { + emptyListView?.visibility = View.VISIBLE + setVideoErrorMessage(getString(R.string.stream_not_possible_headline)) + } } catch (e: Exception) { Log_OC.e(TAG, "Loading stream url not possible: $e") } } } + @Suppress("ReturnCount") + private fun loadStreamUrl(user: User?, clientFactory: ClientFactory?, fileId: Long): Uri? { + val client: OwnCloudClient? = try { + clientFactory?.create(user) + } catch (e: CreationException) { + Log_OC.e(TAG, "Loading stream url not possible: $e") + return null + } + + val sfo = StreamMediaFileOperation(fileId) + val result = sfo.execute(client) + + if (result?.isSuccess == false) { + return null + } + + return (result?.data?.get(0) as String).toUri() + } + private fun playVideoUri(uri: Uri) { binding.progress.visibility = View.GONE @@ -438,56 +465,6 @@ class PreviewMediaFragment : autoplay = false } - @Suppress("DEPRECATION", "ReturnCount") - private class LoadStreamUrl( - previewMediaFragment: PreviewMediaFragment, - private val user: User?, - private val clientFactory: ClientFactory? - ) : AsyncTask() { - private val previewMediaFragmentWeakReference = WeakReference(previewMediaFragment) - - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg fileId: Long?): Uri? { - val client: OwnCloudClient? - try { - client = clientFactory?.create(user) - } catch (e: CreationException) { - Log_OC.e(TAG, "Loading stream url not possible: $e") - return null - } - - val sfo = fileId[0]?.let { StreamMediaFileOperation(it) } - val result = sfo?.execute(client) - - if (result?.isSuccess == false) { - return null - } - - return (result?.data?.get(0) as String).toUri() - } - - @Deprecated("Deprecated in Java") - override fun onPostExecute(uri: Uri?) { - val previewMediaFragment = previewMediaFragmentWeakReference.get() - val context = previewMediaFragment?.context - - if (previewMediaFragment?.binding == null || context == null) { - Log_OC.e(TAG, "Error streaming file: no previewMediaFragment!") - return - } - - previewMediaFragment.run { - if (uri != null) { - videoUri = uri - playVideoUri(uri) - } else { - emptyListView?.visibility = View.VISIBLE - setVideoErrorMessage(getString(R.string.stream_not_possible_headline)) - } - } - } - } - override fun onStop() { releaseVideoPlayer() super.onStop() diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml new file mode 100644 index 000000000000..a4705f2a34a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml new file mode 100644 index 000000000000..2809243d7b08 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3912bf80aca3..0f2f859ecc32 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -473,6 +473,12 @@ - The URL does not match the hostname in the certificate Do you want to trust this certificate anyway? Could not save certificate + Seek forward + Seek backward + Play + Pause + Playing media + Details Hide Issued to: