mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 00:10:32 +05:30
Merge pull request #4590 from Bnyro/submit-sb-segments
feat: support for submitting SponsorBlock segments
This commit is contained in:
commit
fc1260ce4d
@ -1,9 +1,13 @@
|
||||
package com.github.libretube.api
|
||||
|
||||
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.SB_SUBMIT_API_URL
|
||||
import com.github.libretube.obj.update.UpdateInfo
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface ExternalApi {
|
||||
@ -14,4 +18,16 @@ interface ExternalApi {
|
||||
// fetch latest version info
|
||||
@GET(GITHUB_API_URL)
|
||||
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>
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
package com.github.libretube.api.obj
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DeArrowTitle(
|
||||
val UUID: String,
|
||||
@SerialName("UUID") val uuid: String,
|
||||
val locked: Boolean,
|
||||
val original: Boolean,
|
||||
val title: String,
|
||||
|
@ -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>
|
||||
)
|
@ -4,6 +4,7 @@ package com.github.libretube.constants
|
||||
* API link for the update checker
|
||||
*/
|
||||
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
|
||||
|
@ -154,4 +154,9 @@ object PreferenceKeys {
|
||||
* Error logs
|
||||
*/
|
||||
const val ERROR_LOG = "error_log"
|
||||
|
||||
/**
|
||||
* SponsorBlock UUID
|
||||
*/
|
||||
const val SB_USER_ID = "sb_user_id"
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.LoadControl
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import com.github.libretube.LibreTubeApp
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.api.obj.ChapterSegment
|
||||
import com.github.libretube.api.obj.Segment
|
||||
@ -41,17 +42,6 @@ import kotlinx.coroutines.runBlocking
|
||||
object PlayerHelper {
|
||||
private const val ACTION_MEDIA_CONTROL = "media_control"
|
||||
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 ROLE_FLAG_AUTO_GEN_SUBTITLE = C.ROLE_FLAG_SUPPLEMENTARY
|
||||
|
||||
@ -463,7 +453,9 @@ object PlayerHelper {
|
||||
fun getSponsorBlockCategories(): MutableMap<String, SbSkipOptions> {
|
||||
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()
|
||||
if (SbSkipOptions.valueOf(state) != SbSkipOptions.OFF) {
|
||||
categories[category] = SbSkipOptions.valueOf(state)
|
||||
@ -496,7 +488,7 @@ object PlayerHelper {
|
||||
if (sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC ||
|
||||
(
|
||||
sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC_ONCE &&
|
||||
segment.skipped == false
|
||||
!segment.skipped
|
||||
)
|
||||
) {
|
||||
if (sponsorBlockNotifications) {
|
||||
@ -510,7 +502,7 @@ object PlayerHelper {
|
||||
} else if (sponsorBlockConfig[segment.category] == SbSkipOptions.MANUAL ||
|
||||
(
|
||||
sponsorBlockConfig[segment.category] == SbSkipOptions.AUTOMATIC_ONCE &&
|
||||
segment.skipped == true
|
||||
segment.skipped
|
||||
)
|
||||
) {
|
||||
return segment
|
||||
|
@ -6,6 +6,7 @@ import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.libretube.constants.PreferenceKeys
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
object PreferenceHelper {
|
||||
/**
|
||||
@ -18,6 +19,11 @@ object PreferenceHelper {
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@ -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 {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
package com.github.libretube.ui.dialogs
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.text.InputFilter
|
||||
import android.text.format.Formatter
|
||||
import android.util.Log
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isGone
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@ -65,7 +65,7 @@ class DownloadDialog(
|
||||
.setPositiveButton(R.string.download, null)
|
||||
.show()
|
||||
.apply {
|
||||
getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener {
|
||||
onDownloadConfirm.invoke()
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -5,16 +5,22 @@ import android.content.res.Configuration
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.exoplayer.trackselection.TrackSelector
|
||||
import androidx.media3.ui.PlayerView.ControllerVisibilityListener
|
||||
import com.github.libretube.R
|
||||
import com.github.libretube.extensions.toID
|
||||
import com.github.libretube.helpers.PlayerHelper
|
||||
import com.github.libretube.helpers.WindowHelper
|
||||
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.interfaces.OnlinePlayerOptions
|
||||
import com.github.libretube.ui.models.PlayerViewModel
|
||||
import com.github.libretube.util.PlayingQueue
|
||||
|
||||
class OnlinePlayerView(
|
||||
context: Context,
|
||||
@ -157,6 +163,15 @@ class OnlinePlayerView(
|
||||
binding.autoPlay.setOnCheckedChangeListener { _, 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() {
|
||||
|
13
app/src/main/res/drawable/ic_upload_segment.xml
Normal file
13
app/src/main/res/drawable/ic_upload_segment.xml
Normal 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>
|
50
app/src/main/res/layout/dialog_submit_segment.xml
Normal file
50
app/src/main/res/layout/dialog_submit_segment.xml
Normal 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>
|
@ -81,6 +81,15 @@
|
||||
app:track="@drawable/player_switch_track"
|
||||
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
|
||||
android:id="@+id/sb_toggle"
|
||||
android:tooltipText="@string/tooltip_sponsorblock"
|
||||
|
@ -469,4 +469,25 @@
|
||||
<item>auto</item>
|
||||
</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>
|
||||
|
@ -468,6 +468,10 @@
|
||||
<string name="auto_generated">auto-generated</string>
|
||||
<string name="resolution_limited">limited</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 -->
|
||||
<string name="download_channel_name">Download Service</string>
|
||||
<string name="download_channel_description">Shows a notification when downloading media.</string>
|
||||
|
Loading…
x
Reference in New Issue
Block a user