feat(exoplayer): custom subtitle view (#544)

* branch

* Add Custom Subtitles
This commit is contained in:
Sadwhy 2024-12-15 21:19:40 +06:00 committed by GitHub
parent eac4604b3d
commit b04a176870
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 478 additions and 49 deletions

View file

@ -12,6 +12,7 @@ import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.hardware.SensorManager
@ -71,9 +72,12 @@ import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.Util
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource
@ -137,6 +141,7 @@ import ani.dantotsu.others.getSerialized
import ani.dantotsu.parsers.AnimeSources
import ani.dantotsu.parsers.HAnimeSources
import ani.dantotsu.parsers.Subtitle
import ani.dantotsu.others.Xubtitle
import ani.dantotsu.parsers.SubtitleType
import ani.dantotsu.parsers.Video
import ani.dantotsu.parsers.VideoExtractor
@ -226,6 +231,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
private lateinit var animeTitle: TextView
private lateinit var videoInfo: TextView
private lateinit var episodeTitle: Spinner
private lateinit var customSubtitleView: Xubtitle
private var orientationListener: OrientationEventListener? = null
@ -425,6 +431,95 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
}
}
private fun applySubtitleStyles(textView: Xubtitle) {
val primaryColor = when (PrefManager.getVal<Int>(PrefName.PrimaryColor)) {
0 -> Color.BLACK
1 -> Color.DKGRAY
2 -> Color.GRAY
3 -> Color.LTGRAY
4 -> Color.WHITE
5 -> Color.RED
6 -> Color.YELLOW
7 -> Color.GREEN
8 -> Color.CYAN
9 -> Color.BLUE
10 -> Color.MAGENTA
11 -> Color.TRANSPARENT
else -> Color.WHITE
}
val subBackground = when (PrefManager.getVal<Int>(PrefName.SubBackground)) {
0 -> Color.TRANSPARENT
1 -> Color.BLACK
2 -> Color.DKGRAY
3 -> Color.GRAY
4 -> Color.LTGRAY
5 -> Color.WHITE
6 -> Color.RED
7 -> Color.YELLOW
8 -> Color.GREEN
9 -> Color.CYAN
10 -> Color.BLUE
11 -> Color.MAGENTA
else -> Color.TRANSPARENT
}
val font = when (PrefManager.getVal<Int>(PrefName.Font)) {
0 -> ResourcesCompat.getFont(this, R.font.poppins_semi_bold)
1 -> ResourcesCompat.getFont(this, R.font.poppins_bold)
2 -> ResourcesCompat.getFont(this, R.font.poppins)
3 -> ResourcesCompat.getFont(this, R.font.poppins_thin)
4 -> ResourcesCompat.getFont(this, R.font.century_gothic_regular)
5 -> ResourcesCompat.getFont(this, R.font.levenim_mt_bold)
6 -> ResourcesCompat.getFont(this, R.font.blocky)
else -> ResourcesCompat.getFont(this, R.font.poppins_semi_bold)
}
val fontSize = PrefManager.getVal<Int>(PrefName.FontSize).toFloat()
textView.setBackgroundColor(subBackground)
textView.setTextColor(primaryColor)
textView.typeface = font
textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize)
val secondaryColor = when (PrefManager.getVal<Int>(PrefName.SecondaryColor)) {
0 -> Color.BLACK
1 -> Color.DKGRAY
2 -> Color.GRAY
3 -> Color.LTGRAY
4 -> Color.WHITE
5 -> Color.RED
6 -> Color.YELLOW
7 -> Color.GREEN
8 -> Color.CYAN
9 -> Color.BLUE
10 -> Color.MAGENTA
11 -> Color.TRANSPARENT
else -> Color.BLACK
}
val subStroke = PrefManager.getVal<Float>(PrefName.SubStroke)
textView.apply {
when (PrefManager.getVal<Int>(PrefName.Outline)) {
0 -> applyOutline(secondaryColor, subStroke)
1 -> applyShineEffect(secondaryColor)
2 -> applyDropShadow(secondaryColor, subStroke)
3 -> {}
else -> applyOutline(secondaryColor, subStroke)
}
}
textView.alpha =
when (PrefManager.getVal<Boolean>(PrefName.Subtitles)) {
true -> PrefManager.getVal(PrefName.SubAlpha)
false -> 0f
}
val textElevation = PrefManager.getVal<Float>(PrefName.SubBottomMargin) / 30 * resources.displayMetrics.heightPixels
textView.translationY = -textElevation
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -470,6 +565,7 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
skipTimeButton = playerView.findViewById(R.id.exo_skip_timestamp)
skipTimeText = skipTimeButton.findViewById(R.id.exo_skip_timestamp_text)
timeStampText = playerView.findViewById(R.id.exo_time_stamp_text)
customSubtitleView = playerView.findViewById(R.id.customSubtitleView)
animeTitle = playerView.findViewById(R.id.exo_anime_title)
episodeTitle = playerView.findViewById(R.id.exo_ep_sel)
@ -523,7 +619,6 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
it.visibility = View.GONE
}
}
setupSubFormatting(playerView)
if (savedInstanceState != null) {
currentWindow = savedInstanceState.getInt(resumeWindow)
@ -1732,6 +1827,54 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
}
playerView.player = exoPlayer
exoPlayer.addListener(object : Player.Listener {
var activeSubtitles = ArrayDeque<String>(3)
var lastSubtitle: String? = null
var lastPosition: Long = 0
override fun onCues(cueGroup: CueGroup) {
if (PrefManager.getVal<Boolean>(PrefName.TextviewSubtitles)) {
exoSubtitleView.visibility = View.GONE
customSubtitleView.visibility = View.VISIBLE
val newCues = cueGroup.cues.map { it.text.toString() ?: "" }
if (newCues.isEmpty()) {
customSubtitleView.text = ""
activeSubtitles.clear()
lastSubtitle = null
lastPosition = 0
return
}
val currentPosition = exoPlayer.currentPosition
if ((lastSubtitle?.length ?: 0) < 20 || (lastPosition != 0L && currentPosition - lastPosition > 1500)) {
activeSubtitles.clear()
}
for (newCue in newCues) {
if (newCue !in activeSubtitles) {
if (activeSubtitles.size >= 2) {
activeSubtitles.removeLast()
}
activeSubtitles.addFirst(newCue)
lastSubtitle = newCue
lastPosition = currentPosition
}
}
customSubtitleView.text = activeSubtitles.joinToString("\n")
} else {
customSubtitleView.text = ""
customSubtitleView.visibility = View.GONE
exoSubtitleView.visibility = View.VISIBLE
}
}
})
applySubtitleStyles(customSubtitleView)
setupSubFormatting(playerView)
try {
val rightNow = Calendar.getInstance()
mediaSession = MediaSession.Builder(this, exoPlayer)
@ -2021,7 +2164,10 @@ class ExoplayerView : AppCompatActivity(), Player.Listener, SessionAvailabilityL
TrackSelectionOverride(trackGroup.mediaTrackGroup, index)
)
.build()
if (type == TRACK_TYPE_TEXT) setupSubFormatting(playerView)
if (type == TRACK_TYPE_TEXT) {
setupSubFormatting(playerView)
applySubtitleStyles(customSubtitleView)
}
playerView.subtitleView?.alpha = when (isDisabled) {
false -> PrefManager.getVal(PrefName.SubAlpha)
true -> 0f

View file

@ -0,0 +1,144 @@
package ani.dantotsu.others
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
class Xubtitle
@JvmOverloads
constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var outlineThickness: Float = 0f
private var effectColor: Int = currentTextColor
private var currentEffect: Effect = Effect.NONE
private val shadowPaint = Paint().apply { isAntiAlias = true }
private val outlinePaint = Paint().apply { isAntiAlias = true }
private var shineShader: Shader? = null
enum class Effect {
NONE,
OUTLINE,
SHINE,
DROP_SHADOW,
}
override fun onDraw(canvas: Canvas) {
val text = text.toString()
val textPaint =
TextPaint(paint).apply {
color = currentTextColor
}
val staticLayout =
StaticLayout.Builder
.obtain(text, 0, text.length, textPaint, width)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.setLineSpacing(0f, 1f)
.build()
when (currentEffect) {
Effect.OUTLINE -> {
textPaint.style = Paint.Style.STROKE
textPaint.strokeWidth = outlineThickness
textPaint.color = effectColor
staticLayout.draw(canvas)
textPaint.style = Paint.Style.FILL
textPaint.color = currentTextColor
staticLayout.draw(canvas)
}
Effect.DROP_SHADOW -> {
setLayerType(LAYER_TYPE_SOFTWARE, null)
textPaint.setShadowLayer(outlineThickness, 4f, 4f, effectColor)
staticLayout.draw(canvas)
textPaint.clearShadowLayer()
}
Effect.SHINE -> {
val shadowShader =
LinearGradient(
0f,
0f,
width.toFloat(),
height.toFloat(),
intArrayOf(Color.WHITE, effectColor, Color.BLACK),
null,
Shader.TileMode.CLAMP,
)
val shadowPaint =
Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
textSize = textPaint.textSize
typeface = textPaint.typeface
shader = shadowShader
}
canvas.drawText(
text,
x + 4f, // Shadow offset
y + 4f,
shadowPaint,
)
val shader =
LinearGradient(
0f,
0f,
width.toFloat(),
height.toFloat(),
intArrayOf(effectColor, Color.WHITE, Color.WHITE),
null,
Shader.TileMode.CLAMP,
)
textPaint.shader = shader
staticLayout.draw(canvas)
textPaint.shader = null
}
Effect.NONE -> {
staticLayout.draw(canvas)
}
}
}
fun applyOutline(
color: Int,
outlineThickness: Float,
) {
this.effectColor = color
this.outlineThickness = outlineThickness
currentEffect = Effect.OUTLINE
}
// Too hard for me to figure it out
fun applyShineEffect(color: Int) {
this.effectColor = color
currentEffect = Effect.SHINE
}
fun applyDropShadow(
color: Int,
outlineThickness: Float,
) {
this.effectColor = color
this.outlineThickness = outlineThickness
currentEffect = Effect.DROP_SHADOW
}
}

View file

@ -267,16 +267,16 @@ class PlayerSettingsActivity : AppCompatActivity() {
PrefManager.setVal(PrefName.Cast, isChecked)
}
binding.playerSettingsInternalCast.isChecked = PrefManager.getVal(PrefName.UseInternalCast)
binding.playerSettingsInternalCast.setOnCheckedChangeListener { _, isChecked ->
PrefManager.setVal(PrefName.UseInternalCast, isChecked)
}
binding.playerSettingsRotate.isChecked = PrefManager.getVal(PrefName.RotationPlayer)
binding.playerSettingsRotate.setOnCheckedChangeListener { _, isChecked ->
PrefManager.setVal(PrefName.RotationPlayer, isChecked)
}
binding.playerSettingsInternalCast.isChecked = PrefManager.getVal(PrefName.UseInternalCast)
binding.playerSettingsInternalCast.setOnCheckedChangeListener { _, isChecked ->
PrefManager.setVal(PrefName.UseInternalCast, isChecked)
}
binding.playerSettingsAdditionalCodec.isChecked = PrefManager.getVal(PrefName.UseAdditionalCodec)
binding.playerSettingsAdditionalCodec.setOnCheckedChangeListener { _, isChecked ->
PrefManager.setVal(PrefName.UseAdditionalCodec, isChecked)
@ -306,23 +306,50 @@ class PlayerSettingsActivity : AppCompatActivity() {
binding.videoSubColorWindow,
binding.videoSubFont,
binding.videoSubAlpha,
binding.videoSubStroke,
binding.subtitleFontSizeText,
binding.subtitleFontSize
binding.subtitleFontSize,
binding.videoSubLanguage,
binding.subTextSwitch
).forEach {
it.isEnabled = isChecked
it.isClickable = isChecked
it.alpha = when (isChecked) {
true -> 1f
false -> 0.5f
}
}
}
fun toggleExpSubOptions(isChecked: Boolean) {
arrayOf(
binding.videoSubStrokeButton,
binding.videoSubStroke,
binding.videoSubBottomMarginButton,
binding.videoSubBottomMargin
).forEach {
it.isEnabled = isChecked
it.alpha = when (isChecked) {
true -> 1f
false -> 0.5f
}
}
}
binding.subSwitch.isChecked = PrefManager.getVal(PrefName.Subtitles)
binding.subSwitch.setOnCheckedChangeListener { _, isChecked ->
PrefManager.setVal(PrefName.Subtitles, isChecked)
toggleSubOptions(isChecked)
toggleExpSubOptions(binding.subTextSwitch.isChecked && isChecked)
}
toggleSubOptions(binding.subSwitch.isChecked)
binding.subTextSwitch.isChecked = PrefManager.getVal(PrefName.TextviewSubtitles)
binding.subTextSwitch.setOnCheckedChangeListener { _, isChecked ->
PrefManager.setVal(PrefName.TextviewSubtitles, isChecked)
toggleExpSubOptions(isChecked)
}
toggleExpSubOptions(binding.subTextSwitch.isChecked)
val subLanguages = arrayOf(
"Albanian",
"Arabic",
@ -366,17 +393,17 @@ class PlayerSettingsActivity : AppCompatActivity() {
"Urdu",
"Vietnamese",
)
val subLanguageDialog = AlertDialog.Builder(this, R.style.MyPopup)
.setTitle(getString(R.string.subtitle_langauge))
binding.videoSubLanguage.setOnClickListener {
val dialog = subLanguageDialog.setSingleChoiceItems(
customAlertDialog().apply {
setTitle(getString(R.string.subtitle_langauge))
singleChoiceItems(
subLanguages,
PrefManager.getVal(PrefName.SubLanguage)
) { dialog, count ->
) { count ->
PrefManager.setVal(PrefName.SubLanguage, count)
dialog.dismiss()
}.show()
dialog.window?.setDimAmount(0.8f)
}
show()
}
}
val colorsPrimary =
arrayOf(
@ -510,6 +537,22 @@ class PlayerSettingsActivity : AppCompatActivity() {
}
})
binding.videoSubStroke.value = PrefManager.getVal(PrefName.SubStroke)
binding.videoSubStroke.addOnChangeListener(OnChangeListener { _, value, fromUser ->
if (fromUser) {
PrefManager.setVal(PrefName.SubStroke, value)
updateSubPreview()
}
})
binding.videoSubBottomMargin.value = PrefManager.getVal(PrefName.SubBottomMargin)
binding.videoSubBottomMargin.addOnChangeListener(OnChangeListener { _, value, fromUser ->
if (fromUser) {
PrefManager.setVal(PrefName.SubBottomMargin, value)
updateSubPreview()
}
})
val fonts = arrayOf(
"Poppins Semi Bold",
"Poppins Bold",

View file

@ -94,6 +94,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files
CursedSpeeds(Pref(Location.Player, Boolean::class, false)),
Resize(Pref(Location.Player, Int::class, 0)),
Subtitles(Pref(Location.Player, Boolean::class, true)),
TextviewSubtitles(Pref(Location.Player, Boolean::class, false)),
SubLanguage(Pref(Location.Player, Int::class, 9)),
PrimaryColor(Pref(Location.Player, Int::class, 4)),
SecondaryColor(Pref(Location.Player, Int::class, 0)),
@ -101,6 +102,8 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files
SubBackground(Pref(Location.Player, Int::class, 0)),
SubWindow(Pref(Location.Player, Int::class, 0)),
SubAlpha(Pref(Location.Player, Float::class, 1f)),
SubStroke(Pref(Location.Player, Float::class, 8f)),
SubBottomMargin(Pref(Location.Player, Float::class, 4f)),
Font(Pref(Location.Player, Int::class, 0)),
FontSize(Pref(Location.Player, Int::class, 20)),
Locale(Pref(Location.Player, Int::class, 2)),
@ -128,7 +131,7 @@ enum class PrefName(val data: Pref) { //TODO: Split this into multiple files
Pip(Pref(Location.Player, Boolean::class, true)),
RotationPlayer(Pref(Location.Player, Boolean::class, true)),
TorrentEnabled(Pref(Location.Player, Boolean::class, false)),
UseAdditionalCodec(Pref(Location.Player, Boolean::class, true)),
UseAdditionalCodec(Pref(Location.Player, Boolean::class, false)),
//Reader
ShowSource(Pref(Location.Reader, Boolean::class, true)),

View file

@ -381,6 +381,88 @@
</com.google.android.material.slider.Slider>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/subTextSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:checked="true"
android:drawableStart="@drawable/ic_round_subtitles_24"
android:drawablePadding="16dp"
android:elegantTextHeight="false"
android:fontFamily="@font/poppins_bold"
android:minHeight="64dp"
android:paddingHorizontal="32dp"
android:text="@string/textview_sub"
android:textAlignment="viewStart"
android:textColor="@color/bg_opp"
app:cornerRadius="0dp"
app:drawableTint="?attr/colorPrimary"
app:showText="false"
app:thumbTint="@color/button_switch_track" />
<Button
android:id="@+id/videoSubStrokeButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginBottom="8dp"
android:clickable="false"
android:fontFamily="@font/poppins_bold"
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingHorizontal="32dp"
android:text="@string/textview_sub_stroke"
android:textAlignment="viewStart"
android:textAllCaps="false"
android:textColor="@color/bg_opp"
app:cornerRadius="0dp"
app:icon="@drawable/ic_round_color_24"
app:iconPadding="16dp"
app:iconSize="24dp" />
<com.google.android.material.slider.Slider
android:id="@+id/videoSubStroke"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:valueFrom="1"
android:stepSize="1.0"
android:valueTo="30">
</com.google.android.material.slider.Slider>
<Button
android:id="@+id/videoSubBottomMarginButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_marginBottom="8dp"
android:clickable="false"
android:fontFamily="@font/poppins_bold"
android:insetTop="0dp"
android:insetBottom="0dp"
android:paddingHorizontal="32dp"
android:text="@string/textview_sub_bottom_margin"
android:textAlignment="viewStart"
android:textAllCaps="false"
android:textColor="@color/bg_opp"
app:cornerRadius="0dp"
app:icon="@drawable/ic_round_color_24"
app:iconPadding="16dp"
app:iconSize="24dp" />
<com.google.android.material.slider.Slider
android:id="@+id/videoSubBottomMargin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="32dp"
android:valueFrom="1"
android:stepSize="1.0"
android:valueTo="30">
</com.google.android.material.slider.Slider>
<Button
android:id="@+id/videoSubFont"
style="@style/Widget.Material3.Button.TextButton"
@ -1189,27 +1271,6 @@
</com.google.android.material.materialswitch.MaterialSwitch>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/playerSettingsInternalCast"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="false"
android:drawableStart="@drawable/cast_warning"
android:drawablePadding="16dp"
android:elegantTextHeight="true"
android:fontFamily="@font/poppins_bold"
android:minHeight="64dp"
android:paddingHorizontal="32dp"
android:text="@string/try_internal_cast_experimental"
android:textAlignment="viewStart"
android:textColor="@color/bg_opp"
app:cornerRadius="0dp"
app:drawableTint="?attr/colorPrimary"
app:showText="false"
app:thumbTint="@color/button_switch_track">
</com.google.android.material.materialswitch.MaterialSwitch>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/playerSettingsRotate"
android:layout_width="match_parent"
@ -1232,6 +1293,27 @@
</com.google.android.material.materialswitch.MaterialSwitch>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/playerSettingsInternalCast"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="false"
android:drawableStart="@drawable/cast_warning"
android:drawablePadding="16dp"
android:elegantTextHeight="true"
android:fontFamily="@font/poppins_bold"
android:minHeight="64dp"
android:paddingHorizontal="32dp"
android:text="@string/try_internal_cast_experimental"
android:textAlignment="viewStart"
android:textColor="@color/bg_opp"
app:cornerRadius="0dp"
app:drawableTint="?attr/colorPrimary"
app:showText="false"
app:thumbTint="@color/button_switch_track">
</com.google.android.material.materialswitch.MaterialSwitch>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/playerSettingsAdditionalCodec"
android:layout_width="match_parent"

View file

@ -55,6 +55,14 @@
</androidx.media3.ui.SubtitleView>
<ani.dantotsu.others.Xubtitle
android:id="@+id/customSubtitleView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:adjustViewBounds="true"
android:visibility="gone"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/exo_full_area"
android:layout_width="match_parent"

View file

@ -1078,12 +1078,15 @@ Non quae tempore quo provident laudantium qui illo dolor vel quia dolor et exerc
<string name="host">Host</string>
<string name="port">Port</string>
<string name="authentication">Authentication</string>
<string name="proxy">Socks5 Proxy</string>
<string name="proxy_desc">Route All Your Network Traffic Through a Socks5 Proxy</string>
<string name="proxy">SOCKS5 Proxy</string>
<string name="proxy_desc">Route All Your Network Traffic Through a SOCKS5 Proxy</string>
<string name="proxy_setup">Proxy Setup</string>
<string name="proxy_setup_desc">Configure your Socks5 Proxy</string>
<string name="clear_stored_episode">Clear Stored Episode Data</string>
<string name="clear_stored_chapter">Clear Stored Chapter Data</string>
<string name="use_additional_codec">Additional Codec Support</string>
<string name="textview_sub">Textview Subtitles (Experimental)</string>
<string name="textview_sub_stroke">Subtitle Stroke</string>
<string name="textview_sub_bottom_margin">Bottom Margin</string>
</resources>