From 2ac8e0e621909140b558596bdba4763d4153e878 Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 13 Dec 2023 15:02:49 +0100 Subject: [PATCH] Added casting controls to connected dialog. --- .../java/com/futo/platformplayer/UIDialogs.kt | 25 ++++- .../casting/AirPlayCastingDevice.kt | 15 ++- .../platformplayer/casting/CastingDevice.kt | 75 +++++++++------ .../casting/ChomecastCastingDevice.kt | 17 +++- .../casting/FCastCastingDevice.kt | 19 ++-- .../platformplayer/casting/StateCasting.kt | 22 ++++- .../dialogs/ConnectedCastingDialog.kt | 87 +++++++++++++++-- .../mainactivity/main/VideoDetailFragment.kt | 8 ++ .../res/layout/dialog_casting_connected.xml | 95 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 10 files changed, 306 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt index a32c1b80..a3399d2a 100644 --- a/app/src/main/java/com/futo/platformplayer/UIDialogs.kt +++ b/app/src/main/java/com/futo/platformplayer/UIDialogs.kt @@ -11,12 +11,27 @@ import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.* +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast import androidx.core.content.ContextCompat import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.comments.IPlatformComment import com.futo.platformplayer.casting.StateCasting -import com.futo.platformplayer.dialogs.* +import com.futo.platformplayer.dialogs.AutoUpdateDialog +import com.futo.platformplayer.dialogs.AutomaticBackupDialog +import com.futo.platformplayer.dialogs.AutomaticRestoreDialog +import com.futo.platformplayer.dialogs.CastingAddDialog +import com.futo.platformplayer.dialogs.CastingHelpDialog +import com.futo.platformplayer.dialogs.ChangelogDialog +import com.futo.platformplayer.dialogs.CommentDialog +import com.futo.platformplayer.dialogs.ConnectCastingDialog +import com.futo.platformplayer.dialogs.ConnectedCastingDialog +import com.futo.platformplayer.dialogs.ImportDialog +import com.futo.platformplayer.dialogs.ImportOptionsDialog +import com.futo.platformplayer.dialogs.MigrateDialog +import com.futo.platformplayer.dialogs.ProgressDialog import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp @@ -341,11 +356,17 @@ class UIDialogs { val d = StateCasting.instance.activeDevice; if (d != null) { val dialog = ConnectedCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) + } registerDialogOpened(dialog); dialog.setOnDismissListener { registerDialogClosed(dialog) }; dialog.show(); } else { val dialog = ConnectCastingDialog(context); + if (context is Activity) { + dialog.setOwnerActivity(context) + } registerDialogOpened(dialog); val c = context if (c is Activity) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index f390012e..4882e282 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -56,7 +56,8 @@ class AirPlayCastingDevice : CastingDevice { Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)"); - time = resumePosition; + setTime(resumePosition); + setDuration(duration); if (resumePosition > 0.0) { val pos = resumePosition / duration; Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos") @@ -170,8 +171,16 @@ class AirPlayCastingDevice : CastingDevice { } val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue; + setTime(progress); - time = progress; + + val durationIndex = progressInfo.lowercase().indexOf("duration: "); + if (durationIndex == -1) { + continue; + } + + val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue; + setDuration(duration); } catch (e: Throwable) { Logger.w(TAG, "Failed to get server info from AirPlay device.", e) } @@ -196,7 +205,7 @@ class AirPlayCastingDevice : CastingDevice { } override fun changeSpeed(speed: Double) { - this.speed = speed + setSpeed(speed) post("rate?value=$speed") } diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index beeeecc5..c6e046ef 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -1,7 +1,6 @@ package com.futo.platformplayer.casting import com.futo.platformplayer.constructs.Event1 -import com.futo.platformplayer.getNowDiffMiliseconds import com.futo.platformplayer.models.CastingDeviceInfo import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @@ -11,7 +10,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.net.InetAddress -import java.time.OffsetDateTime enum class CastConnectionState { DISCONNECTED, @@ -59,36 +57,58 @@ abstract class CastingDevice { onPlayChanged.emit(value); } }; - var timeReceivedAt: OffsetDateTime = OffsetDateTime.now() - private set; + + private var lastTimeChangeTime_ms: Long = 0 var time: Double = 0.0 - set(value) { - val changed = value != field; - field = value; - if (changed) { - timeReceivedAt = OffsetDateTime.now(); - onTimeChanged.emit(value); - } - }; + private set + + protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastTimeChangeTime_ms && value != time) { + time = value + lastTimeChangeTime_ms = changeTime_ms + onTimeChanged.emit(value) + } + } + + private var lastDurationChangeTime_ms: Long = 0 + var duration: Double = 0.0 + private set + + protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastDurationChangeTime_ms && value != duration) { + duration = value + lastDurationChangeTime_ms = changeTime_ms + onDurationChanged.emit(value) + } + } + + private var lastVolumeChangeTime_ms: Long = 0 var volume: Double = 1.0 - set(value) { - val changed = value != field; - field = value; - if (changed) { - onVolumeChanged.emit(value); - } - }; + private set + + protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) { + volume = value + lastVolumeChangeTime_ms = changeTime_ms + onVolumeChanged.emit(value) + } + } + + private var lastSpeedChangeTime_ms: Long = 0 var speed: Double = 1.0 - set(value) { - val changed = value != field; - field = value; - if (changed) { - onSpeedChanged.emit(value); - } - }; + private set + + protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) { + if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) { + speed = value + lastSpeedChangeTime_ms = changeTime_ms + onSpeedChanged.emit(value) + } + } + val expectedCurrentTime: Double get() { - val diff = timeReceivedAt.getNowDiffMiliseconds().toDouble() / 1000.0; + val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0; return time + diff; }; var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED @@ -104,6 +124,7 @@ abstract class CastingDevice { var onConnectionStateChanged = Event1(); var onPlayChanged = Event1(); var onTimeChanged = Event1(); + var onDurationChanged = Event1(); var onVolumeChanged = Event1(); var onSpeedChanged = Event1(); diff --git a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt index 09f4fbab..89e13933 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/ChomecastCastingDevice.kt @@ -74,7 +74,8 @@ class ChromecastCastingDevice : CastingDevice { Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)"); - time = resumePosition; + setTime(resumePosition); + setDuration(duration); _streamType = streamType; _contentType = contentType; _contentId = contentId; @@ -136,7 +137,7 @@ class ChromecastCastingDevice : CastingDevice { return; } - this.volume = volume + setVolume(volume) val setVolumeObject = JSONObject(); setVolumeObject.put("type", "SET_VOLUME"); @@ -490,7 +491,7 @@ class ChromecastCastingDevice : CastingDevice { if (!sessionIsRunning) { _sessionId = null; _mediaSessionId = null; - time = 0.0; + setTime(0.0); _transportId = null; Logger.w(TAG, "Session not found."); @@ -510,7 +511,7 @@ class ChromecastCastingDevice : CastingDevice { val volumeLevel = volume.getString("level").toDouble(); val volumeMuted = volume.getBoolean("muted"); //val volumeStepInterval = volume.getString("stepInterval").toFloat(); - this.volume = if (volumeMuted) 0.0 else volumeLevel; + setVolume(if (volumeMuted) 0.0 else volumeLevel); Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)"); } else if (type == "MEDIA_STATUS") { @@ -521,10 +522,16 @@ class ChromecastCastingDevice : CastingDevice { val playerState = status.getString("playerState"); val currentTime = status.getDouble("currentTime"); + if (status.has("media")) { + val media = status.getJSONObject("media") + if (media.has("duration")) { + setDuration(media.getDouble("duration")) + } + } isPlaying = playerState == "PLAYING"; if (isPlaying) { - time = currentTime; + setTime(currentTime); } val playbackRate = status.getInt("playbackRate"); diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 4df07285..110a91d6 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -92,7 +92,8 @@ class FCastCastingDevice : CastingDevice { Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)"); - time = resumePosition; + setTime(resumePosition); + setDuration(duration); sendMessage(Opcode.PLAY, FCastPlayMessage( container = contentType, url = contentId, @@ -100,7 +101,7 @@ class FCastCastingDevice : CastingDevice { speed = speed )); - this.speed = speed ?: 1.0 + setSpeed(speed ?: 1.0); } override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { @@ -115,7 +116,8 @@ class FCastCastingDevice : CastingDevice { Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)"); - time = resumePosition; + setTime(resumePosition); + setDuration(duration); sendMessage(Opcode.PLAY, FCastPlayMessage( container = contentType, content = content, @@ -123,7 +125,7 @@ class FCastCastingDevice : CastingDevice { speed = speed )); - this.speed = speed ?: 1.0 + setSpeed(speed ?: 1.0); } override fun changeVolume(volume: Double) { @@ -131,7 +133,7 @@ class FCastCastingDevice : CastingDevice { return; } - this.volume = volume + setVolume(volume); sendMessage(Opcode.SET_VOLUME, FCastSetVolumeMessage(volume)) } @@ -140,7 +142,7 @@ class FCastCastingDevice : CastingDevice { return; } - this.speed = speed + setSpeed(speed); sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(speed)) } @@ -330,7 +332,8 @@ class FCastCastingDevice : CastingDevice { } val playbackUpdate = FCastCastingDevice.json.decodeFromString(json); - time = playbackUpdate.time; + setTime(playbackUpdate.time, playbackUpdate.generationTime); + setDuration(playbackUpdate.duration, playbackUpdate.generationTime); isPlaying = when (playbackUpdate.state) { 1 -> true else -> false @@ -343,7 +346,7 @@ class FCastCastingDevice : CastingDevice { } val volumeUpdate = FCastCastingDevice.json.decodeFromString(json); - volume = volumeUpdate.volume; + setVolume(volumeUpdate.volume, volumeUpdate.generationTime); } Opcode.PLAYBACK_ERROR -> { if (json == null) { diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 685ccdd0..8598acac 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -66,6 +66,8 @@ class StateCasting { val onActiveDeviceConnectionStateChanged = Event2(); val onActiveDevicePlayChanged = Event1(); val onActiveDeviceTimeChanged = Event1(); + val onActiveDeviceDurationChanged = Event1(); + val onActiveDeviceVolumeChanged = Event1(); var activeDevice: CastingDevice? = null; private val _client = ManagedHttpClient(); var _resumeCastingDevice: CastingDeviceInfo? = null; @@ -297,9 +299,11 @@ class StateCasting { val ad = activeDevice; if (ad != null) { Logger.i(TAG, "Stopping previous device because a new one is being connected.") - ad.onPlayChanged.clear(); - ad.onTimeChanged.clear(); - ad.onConnectionStateChanged.clear(); + device.onConnectionStateChanged.clear(); + device.onPlayChanged.clear(); + device.onTimeChanged.clear(); + device.onVolumeChanged.clear(); + device.onDurationChanged.clear(); ad.stop(); } @@ -309,9 +313,11 @@ class StateCasting { if (castConnectionState == CastConnectionState.DISCONNECTED) { Logger.i(TAG, "Clearing events: $castConnectionState"); + device.onConnectionStateChanged.clear(); device.onPlayChanged.clear(); device.onTimeChanged.clear(); - device.onConnectionStateChanged.clear(); + device.onVolumeChanged.clear(); + device.onDurationChanged.clear(); activeDevice = null; } @@ -331,6 +337,12 @@ class StateCasting { device.onPlayChanged.subscribe { invokeInMainScopeIfRequired { onActiveDevicePlayChanged.emit(it) }; } + device.onDurationChanged.subscribe { + invokeInMainScopeIfRequired { onActiveDeviceDurationChanged.emit(it) }; + }; + device.onVolumeChanged.subscribe { + invokeInMainScopeIfRequired { onActiveDeviceVolumeChanged.emit(it) }; + }; device.onTimeChanged.subscribe { invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) }; }; @@ -345,6 +357,8 @@ class StateCasting { device.onConnectionStateChanged.clear(); device.onPlayChanged.clear(); device.onTimeChanged.clear(); + device.onVolumeChanged.clear(); + device.onDurationChanged.clear(); return; } diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt index 619db900..846d4857 100644 --- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt +++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectedCastingDialog.kt @@ -7,12 +7,20 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.widget.Button +import android.widget.ImageButton import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView -import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.R -import com.futo.platformplayer.casting.* +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.casting.AirPlayCastingDevice +import com.futo.platformplayer.casting.CastConnectionState +import com.futo.platformplayer.casting.CastingDevice +import com.futo.platformplayer.casting.ChromecastCastingDevice +import com.futo.platformplayer.casting.FCastCastingDevice +import com.futo.platformplayer.casting.StateCasting +import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider.OnChangeListener @@ -27,8 +35,16 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { private lateinit var _textType: TextView; private lateinit var _buttonDisconnect: LinearLayout; private lateinit var _sliderVolume: Slider; + private lateinit var _sliderPosition: Slider; private lateinit var _layoutVolumeAdjustable: LinearLayout; private lateinit var _layoutVolumeFixed: LinearLayout; + + private lateinit var _buttonPrevious: ImageButton; + private lateinit var _buttonPlay: ImageButton; + private lateinit var _buttonPause: ImageButton; + private lateinit var _buttonStop: ImageButton; + private lateinit var _buttonNext: ImageButton; + private var _device: CastingDevice? = null; override fun onCreate(savedInstanceState: Bundle?) { @@ -42,17 +58,61 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _textType = findViewById(R.id.text_type); _buttonDisconnect = findViewById(R.id.button_disconnect); _sliderVolume = findViewById(R.id.slider_volume); + _sliderPosition = findViewById(R.id.slider_position); _layoutVolumeAdjustable = findViewById(R.id.layout_volume_adjustable); _layoutVolumeFixed = findViewById(R.id.layout_volume_fixed); + _buttonPrevious = findViewById(R.id.button_previous); + _buttonPrevious.setOnClickListener { + (ownerActivity as MainActivity?)?.getFragment()?.previousVideo() + } + + _buttonPlay = findViewById(R.id.button_play); + _buttonPlay.setOnClickListener { + StateCasting.instance.activeDevice?.resumeVideo() + } + + _buttonPause = findViewById(R.id.button_pause); + _buttonPause.setOnClickListener { + StateCasting.instance.activeDevice?.pauseVideo() + } + + _buttonStop = findViewById(R.id.button_stop); + _buttonStop.setOnClickListener { + (ownerActivity as MainActivity?)?.getFragment()?.closeVideoDetails() + StateCasting.instance.activeDevice?.stopVideo() + } + + _buttonNext = findViewById(R.id.button_next); + _buttonNext.setOnClickListener { + (ownerActivity as MainActivity?)?.getFragment()?.nextVideo() + } + _buttonClose.setOnClickListener { dismiss(); }; _buttonDisconnect.setOnClickListener { StateCasting.instance.activeDevice?.stopCasting(); dismiss(); }; + _sliderPosition.addOnChangeListener(OnChangeListener { _, value, fromUser -> + if (!fromUser) { + return@OnChangeListener + } + + val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; + try { + activeDevice.seekVideo(value.toDouble()); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to change volume.", e); + } + }); + //TODO: Check if volume slider is properly hidden in all cases - _sliderVolume.addOnChangeListener(OnChangeListener { _, value, _ -> + _sliderVolume.addOnChangeListener(OnChangeListener { _, value, fromUser -> + if (!fromUser) { + return@OnChangeListener + } + val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener; if (activeDevice.canSetVolume) { try { @@ -71,11 +131,21 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { super.show(); Logger.i(TAG, "Dialog shown."); - _device?.onVolumeChanged?.remove(this); - _device?.onVolumeChanged?.subscribe { + StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); + StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { _sliderVolume.value = it.toFloat(); }; + StateCasting.instance.onActiveDeviceTimeChanged.remove(this); + StateCasting.instance.onActiveDeviceTimeChanged.subscribe { + _sliderPosition.value = it.toFloat(); + }; + + StateCasting.instance.onActiveDeviceDurationChanged.remove(this); + StateCasting.instance.onActiveDeviceDurationChanged.subscribe { + _sliderPosition.valueTo = it.toFloat(); + }; + _device = StateCasting.instance.activeDevice; val d = _device; val isConnected = d != null && d.connectionState == CastConnectionState.CONNECTED; @@ -89,7 +159,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { override fun dismiss() { super.dismiss(); - _device?.onVolumeChanged?.remove(this); + StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); + StateCasting.instance.onActiveDeviceDurationChanged.remove(this); + StateCasting.instance.onActiveDeviceTimeChanged.remove(this); _device = null; StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); } @@ -110,6 +182,9 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) { _textName.text = d.name; _sliderVolume.value = d.volume.toFloat(); + _sliderPosition.valueFrom = 0.0f; + _sliderPosition.valueTo = d.duration.toFloat(); + _sliderPosition.value = d.time.toFloat(); if (d.canSetVolume) { _layoutVolumeAdjustable.visibility = View.VISIBLE; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 37c6669b..cb0abbed 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -67,6 +67,14 @@ class VideoDetailFragment : MainFragment { constructor() : super() { } + fun nextVideo() { + _viewDetail?.nextVideo(true, true, true); + } + + fun previousVideo() { + _viewDetail?.prevVideo(true); + } + override fun onShownWithView(parameter: Any?, isBack: Boolean) { super.onShownWithView(parameter, isBack); Logger.i(TAG, "onShownWithView parameter=$parameter") diff --git a/app/src/main/res/layout/dialog_casting_connected.xml b/app/src/main/res/layout/dialog_casting_connected.xml index 236339b4..8b593e9d 100644 --- a/app/src/main/res/layout/dialog_casting_connected.xml +++ b/app/src/main/res/layout/dialog_casting_connected.xml @@ -51,7 +51,8 @@ android:layout_height="35dp" android:layout_marginStart="20dp" android:layout_marginEnd="20dp" - android:clickable="true"> + android:clickable="true" + android:layout_marginTop="8dp"> + android:layout_marginStart="20dp" + android:layout_marginTop="8dp"> + + + + + + + + + + + + + + + + + + + + Login to view your comments Polycentric is disabled Play Pause + Position Recommendations Subscriptions