Merge pull request #4590 from Bnyro/submit-sb-segments

feat: support for submitting SponsorBlock segments
This commit is contained in:
Bnyro 2023-08-25 19:39:44 +02:00 committed by GitHub
commit fc1260ce4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 268 additions and 17 deletions

View File

@ -1,9 +1,13 @@
package com.github.libretube.api package com.github.libretube.api
import com.github.libretube.api.obj.Instances import com.github.libretube.api.obj.Instances
import com.github.libretube.api.obj.SubmitSegmentResponse
import com.github.libretube.constants.GITHUB_API_URL import com.github.libretube.constants.GITHUB_API_URL
import com.github.libretube.constants.SB_SUBMIT_API_URL
import com.github.libretube.obj.update.UpdateInfo import com.github.libretube.obj.update.UpdateInfo
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
interface ExternalApi { interface ExternalApi {
@ -14,4 +18,16 @@ interface ExternalApi {
// fetch latest version info // fetch latest version info
@GET(GITHUB_API_URL) @GET(GITHUB_API_URL)
suspend fun getUpdateInfo(): UpdateInfo suspend fun getUpdateInfo(): UpdateInfo
@POST(SB_SUBMIT_API_URL)
suspend fun submitSegment(
@Query("videoID") videoId: String,
@Query("startTime") startTime: Float,
@Query("endTime") endTime: Float,
@Query("category") category: String,
@Query("userAgent") userAgent: String,
@Query("userID") userID: String,
@Query("duration") duration: Float? = null,
@Query("description") description: String = ""
): List<SubmitSegmentResponse>
} }

View File

@ -1,10 +1,11 @@
package com.github.libretube.api.obj package com.github.libretube.api.obj
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class DeArrowTitle( data class DeArrowTitle(
val UUID: String, @SerialName("UUID") val uuid: String,
val locked: Boolean, val locked: Boolean,
val original: Boolean, val original: Boolean,
val title: String, val title: String,

View File

@ -0,0 +1,11 @@
package com.github.libretube.api.obj
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SubmitSegmentResponse(
@SerialName("UUID") val uuid: String,
val category: String,
val segment: List<Float>
)

View File

@ -4,6 +4,7 @@ package com.github.libretube.constants
* API link for the update checker * API link for the update checker
*/ */
const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/releases/latest" const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/releases/latest"
const val SB_SUBMIT_API_URL = "https://sponsor.ajay.app/api/skipSegments"
/** /**
* Links for the about fragment * Links for the about fragment

View File

@ -154,4 +154,9 @@ object PreferenceKeys {
* Error logs * Error logs
*/ */
const val ERROR_LOG = "error_log" const val ERROR_LOG = "error_log"
/**
* SponsorBlock UUID
*/
const val SB_USER_ID = "sb_user_id"
} }

View File

@ -24,6 +24,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.LoadControl import androidx.media3.exoplayer.LoadControl
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.CaptionStyleCompat
import com.github.libretube.LibreTubeApp
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Segment import com.github.libretube.api.obj.Segment
@ -41,17 +42,6 @@ import kotlinx.coroutines.runBlocking
object PlayerHelper { object PlayerHelper {
private const val ACTION_MEDIA_CONTROL = "media_control" private const val ACTION_MEDIA_CONTROL = "media_control"
const val CONTROL_TYPE = "control_type" const val CONTROL_TYPE = "control_type"
private val SPONSOR_CATEGORIES =
arrayOf(
"intro",
"selfpromo",
"interaction",
"sponsor",
"outro",
"filler",
"music_offtopic",
"preview"
)
const val SPONSOR_HIGHLIGHT_CATEGORY = "poi_highlight" const val SPONSOR_HIGHLIGHT_CATEGORY = "poi_highlight"
const val ROLE_FLAG_AUTO_GEN_SUBTITLE = C.ROLE_FLAG_SUPPLEMENTARY const val ROLE_FLAG_AUTO_GEN_SUBTITLE = C.ROLE_FLAG_SUPPLEMENTARY
@ -463,7 +453,9 @@ object PlayerHelper {
fun getSponsorBlockCategories(): MutableMap<String, SbSkipOptions> { fun getSponsorBlockCategories(): MutableMap<String, SbSkipOptions> {
val categories: MutableMap<String, SbSkipOptions> = mutableMapOf() val categories: MutableMap<String, SbSkipOptions> = mutableMapOf()
for (category in SPONSOR_CATEGORIES) { for (category in LibreTubeApp.instance.resources.getStringArray(
R.array.sponsorBlockSegments
)) {
val state = PreferenceHelper.getString(category + "_category", "off").uppercase() val state = PreferenceHelper.getString(category + "_category", "off").uppercase()
if (SbSkipOptions.valueOf(state) != SbSkipOptions.OFF) { if (SbSkipOptions.valueOf(state) != SbSkipOptions.OFF) {
categories[category] = SbSkipOptions.valueOf(state) categories[category] = SbSkipOptions.valueOf(state)
@ -496,7 +488,7 @@ object PlayerHelper {
if (sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC || if (sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC ||
( (
sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC_ONCE && sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC_ONCE &&
segment.skipped == false !segment.skipped
) )
) { ) {
if (sponsorBlockNotifications) { if (sponsorBlockNotifications) {
@ -510,7 +502,7 @@ object PlayerHelper {
} else if (sponsorBlockConfig[segment.category] == SbSkipOptions.MANUAL || } else if (sponsorBlockConfig[segment.category] == SbSkipOptions.MANUAL ||
( (
sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC_ONCE && sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC_ONCE &&
segment.skipped == true segment.skipped
) )
) { ) {
return segment return segment

View File

@ -6,6 +6,7 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import java.time.Instant import java.time.Instant
import java.util.UUID
object PreferenceHelper { object PreferenceHelper {
/** /**
@ -18,6 +19,11 @@ object PreferenceHelper {
*/ */
private lateinit var authSettings: SharedPreferences private lateinit var authSettings: SharedPreferences
/**
* Possible chars to use for the SB User ID
*/
private const val USER_ID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
/** /**
* set the context that is being used to access the shared preferences * set the context that is being used to access the shared preferences
*/ */
@ -129,6 +135,16 @@ object PreferenceHelper {
} }
} }
fun getSponsorBlockUserID(): String {
var uuid = getString(PreferenceKeys.SB_USER_ID, "")
if (uuid.isEmpty()) {
// generate a new user id to use for submitting SponsorBlock segments
uuid = (0 until 30).map { USER_ID_CHARS.random() }.joinToString("")
putString(PreferenceKeys.SB_USER_ID, uuid)
}
return uuid
}
private fun getDefaultSharedPreferences(context: Context): SharedPreferences { private fun getDefaultSharedPreferences(context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context) return PreferenceManager.getDefaultSharedPreferences(context)
} }

View File

@ -1,13 +1,13 @@
package com.github.libretube.ui.dialogs package com.github.libretube.ui.dialogs
import android.app.Dialog import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.text.InputFilter import android.text.InputFilter
import android.text.format.Formatter import android.text.format.Formatter
import android.util.Log import android.util.Log
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -65,7 +65,7 @@ class DownloadDialog(
.setPositiveButton(R.string.download, null) .setPositiveButton(R.string.download, null)
.show() .show()
.apply { .apply {
getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener {
onDownloadConfirm.invoke() onDownloadConfirm.invoke()
} }
} }

View File

@ -0,0 +1,97 @@
package com.github.libretube.ui.dialogs
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.util.Log
import android.widget.ArrayAdapter
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.github.libretube.BuildConfig
import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.databinding.DialogSubmitSegmentBinding
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PreferenceHelper
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.lang.Exception
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SubmitSegmentDialog(
private val videoId: String,
private val currentPosition: Long,
private val duration: Long?
) : DialogFragment() {
private var _binding: DialogSubmitSegmentBinding? = null
private val binding = _binding!!
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = DialogSubmitSegmentBinding.inflate(layoutInflater)
binding.startTime.setText((currentPosition.toFloat() / 1000).toString())
val categoryNames = resources.getStringArray(R.array.sponsorBlockSegmentNames)
binding.segmentCategory.adapter = ArrayAdapter(
requireContext(),
R.layout.dropdown_item,
categoryNames
)
return MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.sb_create_segment))
.setView(binding.root)
.setPositiveButton(R.string.okay, null)
.setNegativeButton(R.string.cancel, null)
.show()
.apply {
getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener {
requireDialog().hide()
lifecycleScope.launch {
submitSegment()
dismiss()
}
}
}
}
private suspend fun submitSegment() {
val context = requireContext().applicationContext
val startTime = binding.startTime.text.toString().toFloatOrNull()
var endTime = binding.endTime.text.toString().toFloatOrNull()
if (endTime == null || startTime == null || startTime > endTime) {
context.toastFromMainDispatcher(R.string.sb_invalid_segment)
return
}
if (duration != null) {
// the end time can't be greater than the video duration
endTime = minOf(endTime, duration.toFloat())
}
val categories = resources.getStringArray(R.array.sponsorBlockSegments)
val category = categories[binding.segmentCategory.selectedItemPosition]
val userAgent = "${context.packageName}/${BuildConfig.VERSION_NAME}"
val uuid = PreferenceHelper.getSponsorBlockUserID()
val duration = duration?.let { it.toFloat() / 1000 }
try {
withContext(Dispatchers.IO) {
RetrofitInstance.externalApi
.submitSegment(videoId, startTime, endTime, category, userAgent, uuid, duration)
}
} catch (e: Exception) {
Log.e(TAG(), e.toString())
context.toastFromMainDispatcher(e.localizedMessage.orEmpty())
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -5,16 +5,22 @@ import android.content.res.Configuration
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.C
import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.ui.PlayerView.ControllerVisibilityListener import androidx.media3.ui.PlayerView.ControllerVisibilityListener
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.WindowHelper import com.github.libretube.helpers.WindowHelper
import com.github.libretube.obj.BottomSheetItem import com.github.libretube.obj.BottomSheetItem
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.SubmitSegmentDialog
import com.github.libretube.ui.extensions.toggleSystemBars import com.github.libretube.ui.extensions.toggleSystemBars
import com.github.libretube.ui.interfaces.OnlinePlayerOptions import com.github.libretube.ui.interfaces.OnlinePlayerOptions
import com.github.libretube.ui.models.PlayerViewModel import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.util.PlayingQueue
class OnlinePlayerView( class OnlinePlayerView(
context: Context, context: Context,
@ -157,6 +163,15 @@ class OnlinePlayerView(
binding.autoPlay.setOnCheckedChangeListener { _, isChecked -> binding.autoPlay.setOnCheckedChangeListener { _, isChecked ->
PlayerHelper.autoPlayEnabled = isChecked PlayerHelper.autoPlayEnabled = isChecked
} }
binding.sbSubmit.isVisible = PlayerHelper.sponsorBlockEnabled
binding.sbSubmit.setOnClickListener {
val currentPosition = player?.currentPosition?.takeIf { it != C.TIME_UNSET }
val duration = player?.duration?.takeIf { it != C.TIME_UNSET }
val videoId = PlayingQueue.getCurrent()?.url?.toID() ?: return@setOnClickListener
SubmitSegmentDialog(videoId, currentPosition ?: 0, duration)
.show((context as BaseActivity).supportFragmentManager, null)
}
} }
override fun hideController() { override fun hideController() {

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24.121029dp"
android:tint="@android:color/white"
android:viewportWidth="565.15"
android:viewportHeight="568">
<path
android:fillColor="#ffffff"
android:pathData="m282.58,568a65,65 0,0 1,-34.14 -9.66C95.41,463.94 2.54,300.46 0,121a64.91,64.91 0,0 1,34 -58.09,522.56 522.56,0 0,1 497.16,0 64.91,64.91 0,0 1,34 58.12c-2.53,179.43 -95.4,342.91 -248.42,437.3A65,65 0,0 1,282.58 568ZM282.58,19.69A502.24,502.24 0,0 0,43.4 80.22,45.27 45.27,0 0,0 19.7,120.75c2.44,172.67 91.81,330 239.07,420.83a46.19,46.19 0,0 0,47.61 0C453.64,450.73 543,293.42 545.45,120.75A45.26,45.26 0,0 0,521.75 80.21,502.26 502.26,0 0,0 282.58,19.69Z" />
<path
android:fillColor="#ffffff"
android:pathData="M284.71,42.69A479.9,479.9 0,0 0,54.37 100.42A22.53,22.53 0,0 0,42.67 120.42C45.07,290.26 135.67,438.64 270.83,522.01A22.48,22.48 0,0 0,294.32 522.01C429.48,438.64 520.08,290.26 522.48,120.42A22.53,22.53 0,0 0,510.78 100.42A479.9,479.9 0,0 0,284.71 42.69zM282.57,112.11L282.87,112.11L423.76,365.75L330.3,365.75L330.3,409.21L234.85,409.21L234.85,365.75L141.39,365.75L282.57,112.11z" />
</vector>

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">
<com.google.android.material.textfield.TextInputLayout
style="@style/CustomDialogTextInputLayout"
android:hint="@string/start_time"
android:layout_width="0dp"
android:layout_weight="1">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/start_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/CustomDialogTextInputLayout"
android:hint="@string/end_time"
android:layout_width="0dp"
android:layout_weight="1">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/end_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<com.github.libretube.ui.views.DropdownMenu
android:id="@+id/segment_category"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="15dp"
app:hint="@string/segment_type"
app:icon="@drawable/ic_frame" />
</LinearLayout>

View File

@ -81,6 +81,15 @@
app:track="@drawable/player_switch_track" app:track="@drawable/player_switch_track"
app:trackTint="#88ffffff" /> app:trackTint="#88ffffff" />
<ImageButton
android:id="@+id/sb_submit"
android:tooltipText="@string/sb_create_segment"
style="@style/PlayerControlTop"
android:layout_marginEnd="2dp"
android:src="@drawable/ic_upload_segment"
android:visibility="gone"
app:tint="@android:color/white" />
<ImageButton <ImageButton
android:id="@+id/sb_toggle" android:id="@+id/sb_toggle"
android:tooltipText="@string/tooltip_sponsorblock" android:tooltipText="@string/tooltip_sponsorblock"

View File

@ -469,4 +469,25 @@
<item>auto</item> <item>auto</item>
</string-array> </string-array>
<string-array name="sponsorBlockSegments">
<item>sponsor</item>
<item>intro</item>
<item>selfpromo</item>
<item>interaction</item>
<item>outro</item>
<item>filler</item>
<item>music_offtopic</item>
<item>preview</item>
</string-array>
<string-array name="sponsorBlockSegmentNames">
<item>@string/category_sponsor</item>
<item>@string/category_intro</item>
<item>@string/category_selfpromo</item>
<item>@string/category_interaction</item>
<item>@string/category_outro</item>
<item>@string/category_filler</item>
<item>@string/category_music_offtopic</item>
<item>@string/category_preview</item>
</string-array>
</resources> </resources>

View File

@ -468,6 +468,10 @@
<string name="auto_generated">auto-generated</string> <string name="auto_generated">auto-generated</string>
<string name="resolution_limited">limited</string> <string name="resolution_limited">limited</string>
<string name="registration_disabled">Registration disabled</string> <string name="registration_disabled">Registration disabled</string>
<string name="sb_create_segment">Create segment</string>
<string name="segment_type">Segment type</string>
<string name="sb_invalid_segment">Invalid segment start or end</string>
<!-- Notification channel strings --> <!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string> <string name="download_channel_name">Download Service</string>
<string name="download_channel_description">Shows a notification when downloading media.</string> <string name="download_channel_description">Shows a notification when downloading media.</string>