Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay

This commit is contained in:
Kelvin 2023-12-19 23:08:48 +01:00
commit a4422fdd56
20 changed files with 462 additions and 118 deletions

View File

@ -232,7 +232,11 @@ fun Long.formatDuration(): String {
val minutes = (this % 3600000) / 60000 val minutes = (this % 3600000) / 60000
val seconds = (this % 60000) / 1000 val seconds = (this % 60000) / 1000
return String.format("%02d:%02d:%02d", hours, minutes, seconds) return if (hours > 0) {
String.format("%02d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
} }
fun String.fixHtmlLinks(): Spanned { fun String.fixHtmlLinks(): Spanned {

View File

@ -40,7 +40,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFrag
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.listeners.OrientationManager import com.futo.platformplayer.listeners.OrientationManager
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.models.UrlVideoWithTime import com.futo.platformplayer.models.UrlVideoWithTime
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@ -96,6 +95,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment; lateinit var _fragMainSubscriptionsFeed: SubscriptionsFeedFragment;
lateinit var _fragMainChannel: ChannelFragment; lateinit var _fragMainChannel: ChannelFragment;
lateinit var _fragMainSources: SourcesFragment; lateinit var _fragMainSources: SourcesFragment;
lateinit var _fragMainTutorial: TutorialFragment;
lateinit var _fragMainPlaylists: PlaylistsFragment; lateinit var _fragMainPlaylists: PlaylistsFragment;
lateinit var _fragMainPlaylist: PlaylistFragment; lateinit var _fragMainPlaylist: PlaylistFragment;
lateinit var _fragWatchlist: WatchLaterFragment; lateinit var _fragWatchlist: WatchLaterFragment;
@ -223,6 +223,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
//Main //Main
_fragMainHome = HomeFragment.newInstance(); _fragMainHome = HomeFragment.newInstance();
_fragMainTutorial = TutorialFragment.newInstance()
_fragMainSuggestions = SuggestionsFragment.newInstance(); _fragMainSuggestions = SuggestionsFragment.newInstance();
_fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance(); _fragMainVideoSearchResults = ContentSearchResultsFragment.newInstance();
_fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance(); _fragMainCreatorSearchResults = CreatorSearchResultsFragment.newInstance();
@ -314,6 +315,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragMainCreatorSearchResults.topBar = _fragTopBarSearch; _fragMainCreatorSearchResults.topBar = _fragTopBarSearch;
_fragMainPlaylistSearchResults.topBar = _fragTopBarSearch; _fragMainPlaylistSearchResults.topBar = _fragTopBarSearch;
_fragMainChannel.topBar = _fragTopBarNavigation; _fragMainChannel.topBar = _fragTopBarNavigation;
_fragMainTutorial.topBar = _fragTopBarNavigation;
_fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral; _fragMainSubscriptionsFeed.topBar = _fragTopBarGeneral;
_fragMainSources.topBar = _fragTopBarAdd; _fragMainSources.topBar = _fragTopBarAdd;
_fragMainPlaylists.topBar = _fragTopBarGeneral; _fragMainPlaylists.topBar = _fragTopBarGeneral;
@ -410,6 +412,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateApp.instance.mainAppStartedWithExternalFiles(this); StateApp.instance.mainAppStartedWithExternalFiles(this);
//startActivity(Intent(this, TestActivity::class.java)); //startActivity(Intent(this, TestActivity::class.java));
val sharedPreferences = getSharedPreferences("GrayjayFirstBoot", Context.MODE_PRIVATE)
val isFirstBoot = sharedPreferences.getBoolean("IsFirstBoot", true)
if (isFirstBoot) {
UIDialogs.showConfirmationDialog(this, getString(R.string.do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button), {
navigate(_fragMainTutorial)
})
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
}
} }
@ -968,6 +980,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
inline fun <reified T : Fragment> getFragment() : T { inline fun <reified T : Fragment> getFragment() : T {
return when(T::class) { return when(T::class) {
HomeFragment::class -> _fragMainHome as T; HomeFragment::class -> _fragMainHome as T;
TutorialFragment::class -> _fragMainTutorial as T;
ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T; ContentSearchResultsFragment::class -> _fragMainVideoSearchResults as T;
CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T; CreatorSearchResultsFragment::class -> _fragMainCreatorSearchResults as T;
SuggestionsFragment::class -> _fragMainSuggestions as T; SuggestionsFragment::class -> _fragMainSuggestions as T;

View File

@ -6,11 +6,9 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrThrow import com.futo.platformplayer.getOrThrow
class JSVideoSourceDescriptor: VideoMuxedSourceDescriptor { class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
protected val _obj: V8ValueObject; protected val _obj: V8ValueObject;
override val isUnMuxed: Boolean; override val isUnMuxed: Boolean;

View File

@ -420,7 +420,6 @@ class ChromecastCastingDevice : CastingDevice {
Logger.i(TAG, "Stopped connection loop."); Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED; connectionState = CastConnectionState.DISCONNECTED;
_thread = null;
}.apply { start() }; }.apply { start() };
//Start ping loop //Start ping loop
@ -440,7 +439,6 @@ class ChromecastCastingDevice : CastingDevice {
} }
Logger.i(TAG, "Stopped ping loop."); Logger.i(TAG, "Stopped ping loop.");
_pingThread = null;
}.apply { start() }; }.apply { start() };
} else { } else {
Log.i(TAG, "Threads still alive, not restarted") Log.i(TAG, "Threads still alive, not restarted")

View File

@ -58,11 +58,8 @@ enum class Opcode(val value: Byte) {
PlaybackError(9), PlaybackError(9),
SetSpeed(10), SetSpeed(10),
Version(11), Version(11),
KeyExchange(12), Ping(12),
Encrypted(13), Pong(13);
Ping(14),
Pong(15),
StartEncryption(16);
companion object { companion object {
private val _map = entries.associateBy { it.value } private val _map = entries.associateBy { it.value }
@ -89,26 +86,18 @@ class FCastCastingDevice : CastingDevice {
private var _scopeIO: CoroutineScope? = null; private var _scopeIO: CoroutineScope? = null;
private var _started: Boolean = false; private var _started: Boolean = false;
private var _version: Long = 1; private var _version: Long = 1;
private val _keyPair: KeyPair
private var _aesKey: SecretKeySpec? = null
private val _queuedEncryptedMessages = arrayListOf<FCastEncryptedMessage>()
private var _encryptionStarted = false
private var _thread: Thread? = null private var _thread: Thread? = null
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() { constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
this.name = name; this.name = name;
this.addresses = addresses; this.addresses = addresses;
this.port = port; this.port = port;
_keyPair = generateKeyPair()
} }
constructor(deviceInfo: CastingDeviceInfo) : super() { constructor(deviceInfo: CastingDeviceInfo) : super() {
this.name = deviceInfo.name; this.name = deviceInfo.name;
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray(); this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
this.port = deviceInfo.port; this.port = deviceInfo.port;
_keyPair = generateKeyPair()
} }
override fun getAddresses(): List<InetAddress> { override fun getAddresses(): List<InetAddress> {
@ -301,9 +290,6 @@ class FCastCastingDevice : CastingDevice {
localAddress = _socket?.localAddress; localAddress = _socket?.localAddress;
connectionState = CastConnectionState.CONNECTED; connectionState = CastConnectionState.CONNECTED;
Logger.i(TAG, "Sending KeyExchange.")
send(Opcode.KeyExchange, getKeyExchangeMessage(_keyPair))
val buffer = ByteArray(4096); val buffer = ByteArray(4096);
Logger.i(TAG, "Started receiving."); Logger.i(TAG, "Started receiving.");
@ -362,7 +348,6 @@ class FCastCastingDevice : CastingDevice {
Logger.i(TAG, "Stopped connection loop."); Logger.i(TAG, "Stopped connection loop.");
connectionState = CastConnectionState.DISCONNECTED; connectionState = CastConnectionState.DISCONNECTED;
_thread = null;
}.apply { start() }; }.apply { start() };
} else { } else {
Log.i(TAG, "Thread was still alive, not restarted") Log.i(TAG, "Thread was still alive, not restarted")
@ -415,63 +400,12 @@ class FCastCastingDevice : CastingDevice {
_version = version.version; _version = version.version;
Logger.i(TAG, "Remote version received: $version") Logger.i(TAG, "Remote version received: $version")
} }
Opcode.KeyExchange -> {
if (json == null) {
Logger.w(TAG, "Got KeyExchange without JSON, ignoring.");
return;
}
val keyExchangeMessage: FCastKeyExchangeMessage = FCastCastingDevice.json.decodeFromString(json)
Logger.i(TAG, "Received public key: ${keyExchangeMessage.publicKey}")
_aesKey = computeSharedSecret(_keyPair.private, keyExchangeMessage)
synchronized(_queuedEncryptedMessages) {
for (queuedEncryptedMessages in _queuedEncryptedMessages) {
val decryptedMessage = decryptMessage(_aesKey!!, queuedEncryptedMessages)
val o = Opcode.find(decryptedMessage.opcode.toByte())
handleMessage(o, decryptedMessage.message)
}
_queuedEncryptedMessages.clear()
}
}
Opcode.Ping -> send(Opcode.Pong) Opcode.Ping -> send(Opcode.Pong)
Opcode.Encrypted -> {
if (json == null) {
Logger.w(TAG, "Got Encrypted without JSON, ignoring.");
return;
}
val encryptedMessage: FCastEncryptedMessage = FCastCastingDevice.json.decodeFromString(json)
if (_aesKey != null) {
val decryptedMessage = decryptMessage(_aesKey!!, encryptedMessage)
val o = Opcode.find(decryptedMessage.opcode.toByte())
handleMessage(o, decryptedMessage.message)
} else {
synchronized(_queuedEncryptedMessages) {
if (_queuedEncryptedMessages.size == 15) {
_queuedEncryptedMessages.removeAt(0)
}
_queuedEncryptedMessages.add(encryptedMessage)
}
}
}
Opcode.StartEncryption -> {
_encryptionStarted = true
//TODO: Send decrypted messages waiting for encryption to be established
}
else -> { } else -> { }
} }
} }
private fun send(opcode: Opcode, message: String? = null) { private fun send(opcode: Opcode, message: String? = null) {
val aesKey = _aesKey
if (_encryptionStarted && aesKey != null && opcode != Opcode.Encrypted && opcode != Opcode.KeyExchange && opcode != Opcode.StartEncryption) {
send(Opcode.Encrypted, encryptMessage(aesKey, FCastDecryptedMessage(opcode.value.toLong(), message)))
return
}
try { try {
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0) val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
val size = 1 + data.size val size = 1 + data.size

View File

@ -1,37 +1,27 @@
package com.futo.platformplayer.dialogs package com.futo.platformplayer.dialogs
import android.app.Activity
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.AddSourceActivity
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.QRCaptureActivity
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.adapters.DeviceAdapter import com.futo.platformplayer.views.adapters.DeviceAdapter
import com.google.zxing.integration.android.IntentIntegrator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
class ConnectCastingDialog(context: Context?) : AlertDialog(context) { class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
private lateinit var _imageLoader: ImageView; private lateinit var _imageLoader: ImageView;
@ -80,6 +70,14 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE; _textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE; _recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
}; };
_rememberedAdapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
}
_adapter.onConnect.subscribe { _ ->
dismiss()
UIDialogs.showCastingDialog(context)
}
_recyclerRememberedDevices.adapter = _rememberedAdapter; _recyclerRememberedDevices.adapter = _rememberedAdapter;
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context); _recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);

View File

@ -133,17 +133,17 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
StateCasting.instance.onActiveDeviceVolumeChanged.remove(this); StateCasting.instance.onActiveDeviceVolumeChanged.remove(this);
StateCasting.instance.onActiveDeviceVolumeChanged.subscribe { StateCasting.instance.onActiveDeviceVolumeChanged.subscribe {
_sliderVolume.value = it.toFloat(); _sliderVolume.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
}; };
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.subscribe { StateCasting.instance.onActiveDeviceTimeChanged.subscribe {
_sliderPosition.value = it.toFloat(); _sliderPosition.value = it.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderPosition.valueTo);
}; };
StateCasting.instance.onActiveDeviceDurationChanged.remove(this); StateCasting.instance.onActiveDeviceDurationChanged.remove(this);
StateCasting.instance.onActiveDeviceDurationChanged.subscribe { StateCasting.instance.onActiveDeviceDurationChanged.subscribe {
_sliderPosition.valueTo = it.toFloat(); _sliderPosition.valueTo = it.toFloat().coerceAtLeast(1.0f);
}; };
_device = StateCasting.instance.activeDevice; _device = StateCasting.instance.activeDevice;
@ -152,6 +152,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
setLoading(!isConnected); setLoading(!isConnected);
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState -> StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); }; StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { setLoading(connectionState != CastConnectionState.CONNECTED); };
updateDevice();
}; };
updateDevice(); updateDevice();
@ -181,10 +182,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
} }
_textName.text = d.name; _textName.text = d.name;
_sliderVolume.value = d.volume.toFloat();
_sliderPosition.valueFrom = 0.0f; _sliderPosition.valueFrom = 0.0f;
_sliderPosition.valueTo = d.duration.toFloat(); _sliderVolume.valueFrom = 0.0f;
_sliderPosition.value = d.time.toFloat(); _sliderVolume.value = d.volume.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
_sliderPosition.valueTo = d.duration.toFloat().coerceAtLeast(1.0f);
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(_sliderVolume.valueTo);
if (d.canSetVolume) { if (d.canSetVolume) {
_layoutVolumeAdjustable.visibility = View.VISIBLE; _layoutVolumeAdjustable.visibility = View.VISIBLE;
@ -193,6 +195,44 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_layoutVolumeAdjustable.visibility = View.GONE; _layoutVolumeAdjustable.visibility = View.GONE;
_layoutVolumeFixed.visibility = View.VISIBLE; _layoutVolumeFixed.visibility = View.VISIBLE;
} }
val interactiveControls = listOf(
_sliderPosition,
_sliderVolume,
_buttonPrevious,
_buttonPlay,
_buttonPause,
_buttonStop,
_buttonNext
)
when (d.connectionState) {
CastConnectionState.CONNECTED -> {
enableControls(interactiveControls)
}
CastConnectionState.CONNECTING,
CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls)
}
}
}
private fun enableControls(views: List<View>) {
views.forEach { enableControl(it) }
}
private fun enableControl(view: View) {
view.alpha = 1.0f
view.isEnabled = true
}
private fun disableControls(views: List<View>) {
views.forEach { disableControl(it) }
}
private fun disableControl(view: View) {
view.alpha = 0.4f
view.isEnabled = false
} }
private fun setLoading(isLoading: Boolean) { private fun setLoading(isLoading: Boolean) {

View File

@ -349,6 +349,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }), ButtonDefinition(6, R.drawable.ic_download, R.drawable.ic_download, R.string.downloads, canToggle = false, { it.currentMain is DownloadsFragment }, { it.navigate<DownloadsFragment>() }),
ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }), ButtonDefinition(8, R.drawable.ic_chat, R.drawable.ic_chat_filled, R.string.comments, canToggle = true, { it.currentMain is CommentsFragment }, { it.navigate<CommentsFragment>() }),
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }), ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>() }),
ButtonDefinition(10, R.drawable.ic_quiz, R.drawable.ic_quiz, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>() }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, { ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings, R.string.settings, canToggle = false, { false }, {
val c = it.context ?: return@ButtonDefinition; val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()"); Logger.i(TAG, "settings preventPictureInPicture()");

View File

@ -15,18 +15,17 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.structures.IAsyncPager import com.futo.platformplayer.api.media.structures.IAsyncPager
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.PlatformContentPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.HistoryVideo import com.futo.platformplayer.models.HistoryVideo
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.others.TagsView
import com.futo.platformplayer.views.adapters.HistoryListViewHolder import com.futo.platformplayer.views.adapters.HistoryListViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.others.TagsView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -52,6 +51,7 @@ class HistoryFragment : MainFragment() {
override fun onShownWithView(parameter: Any?, isBack: Boolean) { override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack) super.onShownWithView(parameter, isBack)
_view?.setPager(StateHistory.instance.getHistoryPager()); _view?.setPager(StateHistory.instance.getHistoryPager());
(topBar as NavigationTopBarFragment?)?.onShown("History");
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")

View File

@ -0,0 +1,208 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.*
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.views.pills.WidePillButton
import java.time.OffsetDateTime
class TutorialFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _view: TutorialView? = null;
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
(topBar as NavigationTopBarFragment?)?.onShown(getString(R.string.tutorials));
}
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = TutorialView(this, inflater);
_view = view;
return view;
}
override fun onDestroyMainView() {
super.onDestroyMainView();
_view = null;
}
@SuppressLint("ViewConstructor")
class TutorialView : LinearLayout {
val fragment: TutorialFragment
constructor(fragment: TutorialFragment, inflater: LayoutInflater) : super(inflater.context) {
this.fragment = fragment
orientation = VERTICAL
addView(createHeader("Initial setup"))
initialSetupVideos.forEach {
addView(createTutorialPill(R.drawable.ic_movie, it.name).apply {
onClick.subscribe {
fragment.navigate<VideoDetailFragment>(it)
}
})
}
addView(createHeader("Features"))
featuresVideos.forEach {
addView(createTutorialPill(R.drawable.ic_movie, it.name).apply {
onClick.subscribe {
fragment.navigate<VideoDetailFragment>(it)
}
})
}
}
private fun createHeader(t: String): TextView {
return TextView(context).apply {
textSize = 24.0f
typeface = resources.getFont(R.font.inter_regular)
text = t
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(15.dp(resources), 10.dp(resources), 15.dp(resources), 12.dp(resources))
}
}
}
private fun createTutorialPill(iconPrefix: Int, t: String): WidePillButton {
return WidePillButton(context).apply {
setIconPrefix(iconPrefix)
setText(t)
setIconSuffix(R.drawable.ic_play_notif)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
setMargins(15.dp(resources), 0, 15.dp(resources), 12.dp(resources))
}
}
}
}
class TutorialVideoSourceDescriptor(url: String, duration: Long) : VideoUnMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> = arrayOf(
VideoUrlSource("1080p", url, 1920, 1080, duration, "video/mp4")
)
override val audioSources: Array<IAudioSource> = arrayOf()
}
class TutorialVideo(
uuid: String,
override val name: String,
override val description: String,
thumbnailUrl: String,
videoUrl: String,
override val duration: Long
) : IPlatformVideoDetails {
override val id: PlatformID = PlatformID("tutorial", uuid)
override val contentType: ContentType = ContentType.MEDIA
override val preview: IVideoSourceDescriptor? = null
override val live: IVideoSource? = null
override val dash: IDashManifestSource? = null
override val hls: IHLSManifestSource? = null
override val subtitles: List<ISubtitleSource> = emptyList()
override val shareUrl: String = ""
override val url: String = ""
override val datetime: OffsetDateTime? = OffsetDateTime.parse("2023-12-18T00:00:00Z")
override val thumbnails: Thumbnails = Thumbnails(arrayOf(Thumbnail(thumbnailUrl)))
override val author: PlatformAuthorLink = PlatformAuthorLink(PlatformID("tutorial", "f422ced6-b551-4b62-818e-27a4f5f4918a"), "Grayjay", "", "https://releases.grayjay.app/tutorials/author.jpeg")
override val isLive: Boolean = false
override val rating: IRating = RatingLikes(-1)
override val viewCount: Long = -1
override val video: IVideoSourceDescriptor = TutorialVideoSourceDescriptor(videoUrl, duration)
override fun getComments(client: IPlatformClient): IPager<IPlatformComment> {
return EmptyPager()
}
override fun getPlaybackTracker(): IPlaybackTracker? {
return null
}
}
companion object {
val TAG = "HomeFragment";
fun newInstance() = TutorialFragment().apply {}
val initialSetupVideos = listOf(
TutorialVideo(
uuid = "228be579-ec52-4d93-b9eb-ca74ec08c58a",
name = "How to install",
description = "Learn how to install Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/how-to-install.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/how-to-install.mp4",
duration = 52
),
TutorialVideo(
uuid = "3b99ebfe-2640-4643-bfe0-a0cf04261fc5",
name = "Getting started",
description = "Learn how to get started with Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/getting-started.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/getting-started.mp4",
duration = 50
),
TutorialVideo(
uuid = "793aa009-516c-4581-b82f-a8efdfef4c27",
name = "Is Grayjay free?",
description = "Learn how Grayjay is monetized.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/pay.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/pay.mp4",
duration = 52
)
)
val featuresVideos = listOf(
TutorialVideo(
uuid = "d2238d88-4252-4a91-a12d-b90c049bb7cf",
name = "Searching",
description = "Learn about searching in Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/search.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/search.mp4",
duration = 39
),
TutorialVideo(
uuid = "d2238d88-4252-4a91-a12d-b90c049bb7cf",
name = "Comments",
description = "Learn about Polycentric comments in Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/polycentric.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/polycentric.mp4",
duration = 64
),
TutorialVideo(
uuid = "94d36959-e3fc-4c24-a988-89147067a179",
name = "Casting",
description = "Learn about casting in Grayjay.",
thumbnailUrl = "https://releases.grayjay.app/tutorials/how-to-cast.jpg",
videoUrl = "https://releases.grayjay.app/tutorials/how-to-cast.mp4",
duration = 79
)
)
}
}

View File

@ -252,6 +252,7 @@ class VideoDetailView : ConstraintLayout {
private val _layoutRating: LinearLayout; private val _layoutRating: LinearLayout;
private val _imageDislikeIcon: ImageView; private val _imageDislikeIcon: ImageView;
private val _imageLikeIcon: ImageView; private val _imageLikeIcon: ImageView;
private val _layoutToggleCommentSection: LinearLayout;
private val _monetization: MonetizationView; private val _monetization: MonetizationView;
@ -328,6 +329,7 @@ class VideoDetailView : ConstraintLayout {
_upNext = findViewById(R.id.up_next); _upNext = findViewById(R.id.up_next);
_textCommentType = findViewById(R.id.text_comment_type); _textCommentType = findViewById(R.id.text_comment_type);
_toggleCommentType = findViewById(R.id.toggle_comment_type); _toggleCommentType = findViewById(R.id.toggle_comment_type);
_layoutToggleCommentSection = findViewById(R.id.layout_toggle_comment_section);
_overlayContainer = findViewById(R.id.overlay_container); _overlayContainer = findViewById(R.id.overlay_container);
_overlay_quality_container = findViewById(R.id.videodetail_quality_overview); _overlay_quality_container = findViewById(R.id.videodetail_quality_overview);
@ -434,7 +436,7 @@ class VideoDetailView : ConstraintLayout {
_buttonPins.alwaysShowLastButton = true; _buttonPins.alwaysShowLastButton = true;
var buttonMore: RoundButton? = null; var buttonMore: RoundButton? = null;
buttonMore = RoundButton(context, R.drawable.ic_menu, "More", TAG_MORE) { buttonMore = RoundButton(context, R.drawable.ic_menu, context.getString(R.string.more), TAG_MORE) {
_slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE)) {selected -> _slideUpOverlay = UISlideOverlays.showMoreButtonOverlay(_overlayContainer, _buttonPins, listOf(TAG_MORE)) {selected ->
_buttonPins.setButtons(*(selected + listOf(buttonMore!!)).toTypedArray()); _buttonPins.setButtons(*(selected + listOf(buttonMore!!)).toTypedArray());
_buttonPinStore.set(*selected.filter { it.tagRef is String }.map{ it.tagRef as String }.toTypedArray()) _buttonPinStore.set(*selected.filter { it.tagRef is String }.map{ it.tagRef as String }.toTypedArray())
@ -444,7 +446,6 @@ class VideoDetailView : ConstraintLayout {
_buttonMore = buttonMore; _buttonMore = buttonMore;
updateMoreButtons(); updateMoreButtons();
_channelButton.setOnClickListener { _channelButton.setOnClickListener {
(video?.author ?: _searchVideo?.author)?.let { (video?.author ?: _searchVideo?.author)?.let {
fragment.navigate<ChannelFragment>(it); fragment.navigate<ChannelFragment>(it);
@ -1205,7 +1206,12 @@ class VideoDetailView : ConstraintLayout {
_player.setMetadata(video.name, video.author.name); _player.setMetadata(video.name, video.author.name);
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false); if (video !is TutorialFragment.TutorialVideo) {
_toggleCommentType.setValue(false, false);
} else {
_toggleCommentType.setValue(!Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1, false);
}
updateCommentType(true); updateCommentType(true);
//UI //UI
@ -1392,6 +1398,20 @@ class VideoDetailView : ConstraintLayout {
_player.updateNextPrevious(); _player.updateNextPrevious();
updateMoreButtons(); updateMoreButtons();
if (videoDetail is TutorialFragment.TutorialVideo) {
_buttonSubscribe.visibility = View.GONE
_buttonMore.visibility = View.GONE
_buttonPins.visibility = View.GONE
_layoutRating.visibility = View.GONE
_layoutToggleCommentSection.visibility = View.GONE
} else {
_buttonSubscribe.visibility = View.VISIBLE
_buttonMore.visibility = View.VISIBLE
_buttonPins.visibility = View.VISIBLE
_layoutRating.visibility = View.VISIBLE
_layoutToggleCommentSection.visibility = View.VISIBLE
}
} }
fun loadLiveChat(video: IPlatformVideoDetails) { fun loadLiveChat(video: IPlatformVideoDetails) {
_liveChat?.stop(); _liveChat?.stop();

View File

@ -5,7 +5,6 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.casting.CastingDevice import com.futo.platformplayer.casting.CastingDevice
import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> { class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
@ -13,6 +12,7 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
private val _isRememberedDevice: Boolean; private val _isRememberedDevice: Boolean;
var onRemove = Event1<CastingDevice>(); var onRemove = Event1<CastingDevice>();
var onConnect = Event1<CastingDevice>();
constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() { constructor(devices: ArrayList<CastingDevice>, isRememberedDevice: Boolean) : super() {
_devices = devices; _devices = devices;
@ -26,6 +26,7 @@ class DeviceAdapter : RecyclerView.Adapter<DeviceViewHolder> {
val holder = DeviceViewHolder(view); val holder = DeviceViewHolder(view);
holder.setIsRememberedDevice(_isRememberedDevice); holder.setIsRememberedDevice(_isRememberedDevice);
holder.onRemove.subscribe { d -> onRemove.emit(d); }; holder.onRemove.subscribe { d -> onRemove.emit(d); };
holder.onConnect.subscribe { d -> onConnect.emit(d); }
return holder; return holder;
} }

View File

@ -31,6 +31,7 @@ class DeviceViewHolder : ViewHolder {
private set private set
var onRemove = Event1<CastingDevice>(); var onRemove = Event1<CastingDevice>();
val onConnect = Event1<CastingDevice>();
constructor(view: View) : super(view) { constructor(view: View) : super(view) {
_imageDevice = view.findViewById(R.id.image_device); _imageDevice = view.findViewById(R.id.image_device);
@ -56,7 +57,7 @@ class DeviceViewHolder : ViewHolder {
val dev = device ?: return@setOnClickListener; val dev = device ?: return@setOnClickListener;
StateCasting.instance.activeDevice?.stopCasting(); StateCasting.instance.activeDevice?.stopCasting();
StateCasting.instance.connectDevice(dev); StateCasting.instance.connectDevice(dev);
updateButton(); onConnect.emit(dev);
}; };
_buttonRemove.setOnClickListener { _buttonRemove.setOnClickListener {
@ -64,6 +65,10 @@ class DeviceViewHolder : ViewHolder {
onRemove.emit(dev); onRemove.emit(dev);
}; };
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, _ ->
updateButton();
}
setIsRememberedDevice(false); setIsRememberedDevice(false);
} }

View File

@ -0,0 +1,57 @@
package com.futo.platformplayer.views.pills
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
class WidePillButton : LinearLayout {
private val _iconPrefix: ImageView
private val _iconSuffix: ImageView
private val _text: TextView
val onClick = Event0()
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
LayoutInflater.from(context).inflate(R.layout.view_wide_pill_button, this, true)
_iconPrefix = findViewById(R.id.image_prefix)
_iconSuffix = findViewById(R.id.image_suffix)
_text = findViewById(R.id.text)
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.WidePillButton, 0, 0)
setIconPrefix(attrArr.getResourceId(R.styleable.WidePillButton_widePillIconPrefix, -1))
setIconSuffix(attrArr.getResourceId(R.styleable.WidePillButton_widePillIconSuffix, -1))
setText(attrArr.getText(R.styleable.PillButton_pillText) ?: "")
attrArr.recycle()
findViewById<LinearLayout>(R.id.root).setOnClickListener {
onClick.emit()
}
}
fun setIconPrefix(drawable: Int) {
if (drawable != -1) {
_iconPrefix.setImageResource(drawable)
_iconPrefix.visibility = View.VISIBLE
} else {
_iconPrefix.visibility = View.GONE
}
}
fun setIconSuffix(drawable: Int) {
if (drawable != -1) {
_iconSuffix.setImageResource(drawable)
_iconSuffix.visibility = View.VISIBLE
} else {
_iconSuffix.visibility = View.GONE
}
}
fun setText(t: CharSequence) {
_text.text = t
}
}

View File

@ -357,7 +357,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
UIDialogs.showCastingDialog(context); UIDialogs.showCastingDialog(context);
}; };
videoControls.setProgressUpdateListener { position, bufferedPosition -> val progressUpdateListener = { position: Long, bufferedPosition: Long ->
val currentTime = position.formatDuration() val currentTime = position.formatDuration()
val currentDuration = duration.formatDuration() val currentDuration = duration.formatDuration()
_control_time.text = currentTime; _control_time.text = currentTime;
@ -380,7 +380,10 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
updateChaptersLoop(++_currentChapterLoopId); updateChaptersLoop(++_currentChapterLoopId);
} }
} }
} };
_videoControls_fullscreen.setProgressUpdateListener(progressUpdateListener);
videoControls.setProgressUpdateListener(progressUpdateListener);
StatePlayer.instance.onQueueChanged.subscribe(this) { StatePlayer.instance.onQueueChanged.subscribe(this) {
CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) {

View File

@ -488,22 +488,28 @@
android:layout_weight="1" android:layout_weight="1"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<TextView <LinearLayout
android:id="@+id/text_comment_type" android:id="@+id/layout_toggle_comment_section"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content">
android:fontFamily="@font/inter_extra_light"
android:textSize="14dp"
android:textColor="@color/white"
android:text="@string/polycentric"
android:layout_marginEnd="8dp" />
<com.futo.platformplayer.views.others.Toggle <TextView
android:id="@+id/toggle_comment_type" android:id="@+id/text_comment_type"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:toggleEnabled="false" android:fontFamily="@font/inter_extra_light"
android:layout_marginEnd="14dp" /> android:textSize="14dp"
android:textColor="@color/white"
android:text="@string/polycentric"
android:layout_marginEnd="8dp" />
<com.futo.platformplayer.views.others.Toggle
android:id="@+id/toggle_comment_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:toggleEnabled="false"
android:layout_marginEnd="14dp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
<com.futo.platformplayer.views.comments.AddCommentView <com.futo.platformplayer.views.comments.AddCommentView

View File

@ -150,7 +150,8 @@
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="8dp" android:paddingBottom="8dp"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="16dp"> android:paddingEnd="16dp"
android:layout_marginStart="12dp">
<TextView <TextView
android:id="@+id/pill_text" android:id="@+id/pill_text"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="50dp"
android:paddingTop="6dp"
android:paddingBottom="7dp"
android:paddingStart="7dp"
android:paddingEnd="12dp"
android:background="@drawable/background_pill"
android:id="@+id/root"
android:gravity="center_vertical">
<ImageView
android:id="@+id/image_prefix"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginRight="12dp"
android:layout_marginLeft="12dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_thumb_up" />
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:textColor="@color/white"
android:textSize="16sp"
android:gravity="center_vertical"
android:fontFamily="@font/inter_light"
tools:text="500K" />
<Space android:layout_height="match_parent"
android:layout_width="0dp"
android:layout_weight="1" />
<ImageView
android:id="@+id/image_suffix"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginRight="12dp"
android:layout_marginLeft="12dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_thumb_up" />
</LinearLayout>

View File

@ -722,6 +722,8 @@
<string name="polycentric_is_disabled">Polycentric is disabled</string> <string name="polycentric_is_disabled">Polycentric is disabled</string>
<string name="play_pause">Play Pause</string> <string name="play_pause">Play Pause</string>
<string name="position">Position</string> <string name="position">Position</string>
<string name="tutorials">Tutorials</string>
<string name="do_you_want_to_see_the_tutorials_you_can_find_them_at_any_time_through_the_more_button">Do you want to see the tutorials? You can find them at any time through the more button.</string>
<string-array name="home_screen_array"> <string-array name="home_screen_array">
<item>Recommendations</item> <item>Recommendations</item>
<item>Subscriptions</item> <item>Subscriptions</item>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="WidePillButton">
<attr name="widePillIconPrefix" format="reference" />
<attr name="widePilllText" format="string" />
<attr name="widePillIconSuffix" format="reference" />
</declare-styleable>
</resources>