Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
+1
-1
@@ -191,7 +191,7 @@ module.exports = function (config) {
|
|||||||
'expo-build-properties',
|
'expo-build-properties',
|
||||||
{
|
{
|
||||||
ios: {
|
ios: {
|
||||||
deploymentTarget: '14.0',
|
deploymentTarget: '15.1',
|
||||||
newArchEnabled: false,
|
newArchEnabled: false,
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
+2
-1
@@ -139,7 +139,7 @@
|
|||||||
"expo-system-ui": "~3.0.4",
|
"expo-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.1",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.14",
|
"expo-updates": "~0.25.14",
|
||||||
"expo-video": "^1.2.4",
|
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
@@ -180,6 +180,7 @@
|
|||||||
"react-native-image-crop-picker": "0.40.3",
|
"react-native-image-crop-picker": "0.40.3",
|
||||||
"react-native-ios-context-menu": "^1.15.3",
|
"react-native-ios-context-menu": "^1.15.3",
|
||||||
"react-native-keyboard-controller": "^1.12.1",
|
"react-native-keyboard-controller": "^1.12.1",
|
||||||
|
"react-native-mmkv": "^2.12.2",
|
||||||
"react-native-pager-view": "6.2.3",
|
"react-native-pager-view": "6.2.3",
|
||||||
"react-native-picker-select": "^9.1.3",
|
"react-native-picker-select": "^9.1.3",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
|
|||||||
@@ -1,608 +0,0 @@
|
|||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
|
||||||
index 473f964..f37aff9 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
|
||||||
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
|
|
||||||
override val name = "playToEnd"
|
|
||||||
}
|
|
||||||
|
|
||||||
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
|
|
||||||
+ override val name = "timeRemainingChange"
|
|
||||||
+ override val arguments = arrayOf(timeRemaining)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
fun emit(player: VideoPlayer, listeners: List<VideoPlayerListener>) {
|
|
||||||
when (this) {
|
|
||||||
is StatusChanged -> listeners.forEach { it.onStatusChanged(player, status, oldStatus, error) }
|
|
||||||
@@ -49,6 +54,7 @@ sealed class PlayerEvent {
|
|
||||||
is SourceChanged -> listeners.forEach { it.onSourceChanged(player, source, oldSource) }
|
|
||||||
is PlaybackRateChanged -> listeners.forEach { it.onPlaybackRateChanged(player, rate, oldRate) }
|
|
||||||
is PlayedToEnd -> listeners.forEach { it.onPlayedToEnd(player) }
|
|
||||||
+ is PlayerTimeRemainingChanged -> listeners.forEach { it.onPlayerTimeRemainingChanged(player, timeRemaining) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
index 9905e13..47342ff 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
|
|
||||||
setShowPreviousButton(!requireLinearPlayback)
|
|
||||||
setShowNextButton(!requireLinearPlayback)
|
|
||||||
setTimeBarInteractive(requireLinearPlayback)
|
|
||||||
+ setShowSubtitleButton(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
|
||||||
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
+ val fullscreenButton =
|
|
||||||
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
fullscreenButton?.visibility = if (visible) {
|
|
||||||
android.view.View.VISIBLE
|
|
||||||
} else {
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..0249e23
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
|
||||||
@@ -0,0 +1,29 @@
|
|
||||||
+import android.os.Handler
|
|
||||||
+import android.os.Looper
|
|
||||||
+import androidx.annotation.OptIn
|
|
||||||
+import androidx.media3.common.util.UnstableApi
|
|
||||||
+import expo.modules.video.PlayerEvent
|
|
||||||
+import expo.modules.video.VideoPlayer
|
|
||||||
+import kotlin.math.floor
|
|
||||||
+
|
|
||||||
+@OptIn(UnstableApi::class)
|
|
||||||
+class ProgressTracker(private val videoPlayer: VideoPlayer) : Runnable {
|
|
||||||
+ private val handler: Handler = Handler(Looper.getMainLooper())
|
|
||||||
+ private val player = videoPlayer.player
|
|
||||||
+
|
|
||||||
+ init {
|
|
||||||
+ handler.post(this)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ override fun run() {
|
|
||||||
+ val currentPosition = player.currentPosition
|
|
||||||
+ val duration = player.duration
|
|
||||||
+ val timeRemaining = floor(((duration - currentPosition) / 1000).toDouble())
|
|
||||||
+ videoPlayer.sendEvent(PlayerEvent.PlayerTimeRemainingChanged(timeRemaining))
|
|
||||||
+ handler.postDelayed(this, 1000 /* ms */)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ fun remove() {
|
|
||||||
+ handler.removeCallbacks(this)
|
|
||||||
+ }
|
|
||||||
+}
|
|
||||||
\ No newline at end of file
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
|
|
||||||
index 4b6c6d8..e20f51a 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
|
|
||||||
@@ -1,5 +1,6 @@
|
|
||||||
package expo.modules.video
|
|
||||||
|
|
||||||
+import android.provider.MediaStore.Video
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import expo.modules.kotlin.AppContext
|
|
||||||
@@ -15,6 +16,8 @@ object VideoManager {
|
|
||||||
// Keeps track of all existing VideoPlayers, and whether they are attached to a VideoView
|
|
||||||
private var videoPlayersToVideoViews = mutableMapOf<VideoPlayer, MutableList<VideoView>>()
|
|
||||||
|
|
||||||
+ private var previouslyPlayingViews: MutableList<VideoView>? = null
|
|
||||||
+
|
|
||||||
private lateinit var audioFocusManager: AudioFocusManager
|
|
||||||
|
|
||||||
fun onModuleCreated(appContext: AppContext) {
|
|
||||||
@@ -69,16 +72,24 @@ object VideoManager {
|
|
||||||
return videoPlayersToVideoViews[videoPlayer]?.isNotEmpty() ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
- fun onAppForegrounded() = Unit
|
|
||||||
+ fun onAppForegrounded() {
|
|
||||||
+ val previouslyPlayingViews = this.previouslyPlayingViews ?: return
|
|
||||||
+ for (videoView in previouslyPlayingViews) {
|
|
||||||
+ val player = videoView.videoPlayer?.player ?: continue
|
|
||||||
+ player.play()
|
|
||||||
+ }
|
|
||||||
+ this.previouslyPlayingViews = null
|
|
||||||
+ }
|
|
||||||
|
|
||||||
fun onAppBackgrounded() {
|
|
||||||
+ val previouslyPlayingViews = mutableListOf<VideoView>()
|
|
||||||
for (videoView in videoViews.values) {
|
|
||||||
- if (videoView.videoPlayer?.staysActiveInBackground == false &&
|
|
||||||
- !videoView.willEnterPiP &&
|
|
||||||
- !videoView.isInFullscreen
|
|
||||||
- ) {
|
|
||||||
- videoView.videoPlayer?.player?.pause()
|
|
||||||
+ val player = videoView.videoPlayer?.player ?: continue
|
|
||||||
+ if (player.isPlaying) {
|
|
||||||
+ player.pause()
|
|
||||||
+ previouslyPlayingViews.add(videoView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+ this.previouslyPlayingViews = previouslyPlayingViews
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
index ec3da2a..5a1397a 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
@@ -43,7 +43,9 @@ class VideoModule : Module() {
|
|
||||||
View(VideoView::class) {
|
|
||||||
Events(
|
|
||||||
"onPictureInPictureStart",
|
|
||||||
- "onPictureInPictureStop"
|
|
||||||
+ "onPictureInPictureStop",
|
|
||||||
+ "onEnterFullscreen",
|
|
||||||
+ "onExitFullscreen"
|
|
||||||
)
|
|
||||||
|
|
||||||
Prop("player") { view: VideoView, player: VideoPlayer ->
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
|
||||||
index 58f00af..5ad8237 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
|
||||||
@@ -1,5 +1,6 @@
|
|
||||||
package expo.modules.video
|
|
||||||
|
|
||||||
+import ProgressTracker
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.SurfaceView
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
@@ -35,11 +36,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
.Builder(context, renderersFactory)
|
|
||||||
.setLooper(context.mainLooper)
|
|
||||||
.build()
|
|
||||||
+ var progressTracker: ProgressTracker? = null
|
|
||||||
|
|
||||||
val serviceConnection = PlaybackServiceConnection(WeakReference(player))
|
|
||||||
|
|
||||||
var playing by IgnoreSameSet(false) { new, old ->
|
|
||||||
sendEvent(PlayerEvent.IsPlayingChanged(new, old))
|
|
||||||
+ addOrRemoveProgressTracker()
|
|
||||||
}
|
|
||||||
|
|
||||||
var uncommittedSource: VideoSource? = source
|
|
||||||
@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
+ this.progressTracker?.remove()
|
|
||||||
+ this.progressTracker = null
|
|
||||||
+
|
|
||||||
appContext?.reactContext?.unbindService(serviceConnection)
|
|
||||||
serviceConnection.playbackServiceBinder?.service?.unregisterPlayer(player)
|
|
||||||
VideoManager.unregisterVideoPlayer(this@VideoPlayer)
|
|
||||||
@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
listeners.removeAll { it.get() == videoPlayerListener }
|
|
||||||
}
|
|
||||||
|
|
||||||
- private fun sendEvent(event: PlayerEvent) {
|
|
||||||
+ fun sendEvent(event: PlayerEvent) {
|
|
||||||
// Emits to the native listeners
|
|
||||||
event.emit(this, listeners.mapNotNull { it.get() })
|
|
||||||
// Emits to the JS side
|
|
||||||
@@ -240,4 +246,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
sendEvent(eventName, *args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ private fun addOrRemoveProgressTracker() {
|
|
||||||
+ this.progressTracker?.remove()
|
|
||||||
+ if (this.playing) {
|
|
||||||
+ this.progressTracker = ProgressTracker(this)
|
|
||||||
+ } else {
|
|
||||||
+ this.progressTracker = null
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
|
||||||
index f654254..dcfe3f0 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
|
||||||
@@ -15,4 +15,5 @@ interface VideoPlayerListener {
|
|
||||||
fun onSourceChanged(player: VideoPlayer, source: VideoSource?, oldSource: VideoSource?) {}
|
|
||||||
fun onPlaybackRateChanged(player: VideoPlayer, rate: Float, oldRate: Float?) {}
|
|
||||||
fun onPlayedToEnd(player: VideoPlayer) {}
|
|
||||||
+ fun onPlayerTimeRemainingChanged(player: VideoPlayer, timeRemaining: Double) {}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
index a951d80..3932535 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
val playerView: PlayerView = PlayerView(context.applicationContext)
|
|
||||||
val onPictureInPictureStart by EventDispatcher<Unit>()
|
|
||||||
val onPictureInPictureStop by EventDispatcher<Unit>()
|
|
||||||
+ val onEnterFullscreen by EventDispatcher()
|
|
||||||
+ val onExitFullscreen by EventDispatcher()
|
|
||||||
|
|
||||||
var willEnterPiP: Boolean = false
|
|
||||||
var isInFullscreen: Boolean = false
|
|
||||||
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
currentActivity.overridePendingTransition(0, 0)
|
|
||||||
}
|
|
||||||
+ onEnterFullscreen(mapOf())
|
|
||||||
isInFullscreen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
|
|
||||||
videoPlayer?.changePlayerView(playerView)
|
|
||||||
+ this.onExitFullscreen(mapOf())
|
|
||||||
isInFullscreen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
|
||||||
index a09fcfe..46cbae7 100644
|
|
||||||
--- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
|
||||||
+++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
|
||||||
@@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
|
|
||||||
* Handler for an event emitted when the current media source of the player changes.
|
|
||||||
*/
|
|
||||||
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
|
||||||
+
|
|
||||||
+ timeRemainingChange(timeRemaining: number): void;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Describes the current status of the player.
|
|
||||||
@@ -136,7 +138,7 @@ export type VideoPlayerEvents = {
|
|
||||||
* - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
|
|
||||||
* - `error`: The player has encountered an error while loading or playing the video.
|
|
||||||
*/
|
|
||||||
-export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
|
|
||||||
+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
|
|
||||||
export type VideoSource = string | {
|
|
||||||
/**
|
|
||||||
* The URI of the video.
|
|
||||||
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
index cb9ca6d..ed8bb7e 100644
|
|
||||||
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
|
|
||||||
* @platform ios 16.0+
|
|
||||||
*/
|
|
||||||
allowsVideoFrameAnalysis?: boolean;
|
|
||||||
+
|
|
||||||
+ onEnterFullscreen?: () => void;
|
|
||||||
+ onExitFullscreen?: () => void;
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
|
||||||
\ No newline at end of file
|
|
||||||
diff --git a/node_modules/expo-video/ios/Enums/PlayerStatus.swift b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
|
|
||||||
index 6af69ca..189fbbe 100644
|
|
||||||
--- a/node_modules/expo-video/ios/Enums/PlayerStatus.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
|
|
||||||
@@ -6,5 +6,8 @@ internal enum PlayerStatus: String, Enumerable {
|
|
||||||
case idle
|
|
||||||
case loading
|
|
||||||
case readyToPlay
|
|
||||||
+ case waitingToPlayAtSpecifiedRate
|
|
||||||
+ case unlikeToKeepUp
|
|
||||||
+ case playbackBufferEmpty
|
|
||||||
case error
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift
|
|
||||||
index 094a8b0..16e7081 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoManager.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoManager.swift
|
|
||||||
@@ -12,6 +12,7 @@ class VideoManager {
|
|
||||||
|
|
||||||
private var videoViews = NSHashTable<VideoView>.weakObjects()
|
|
||||||
private var videoPlayers = NSHashTable<VideoPlayer>.weakObjects()
|
|
||||||
+ private var previouslyPlayingPlayers: [VideoPlayer]?
|
|
||||||
|
|
||||||
func register(videoPlayer: VideoPlayer) {
|
|
||||||
videoPlayers.add(videoPlayer)
|
|
||||||
@@ -33,63 +34,70 @@ class VideoManager {
|
|
||||||
for videoPlayer in videoPlayers.allObjects {
|
|
||||||
videoPlayer.setTracksEnabled(true)
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ if let previouslyPlayingPlayers = self.previouslyPlayingPlayers {
|
|
||||||
+ previouslyPlayingPlayers.forEach { player in
|
|
||||||
+ player.pointer.play()
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
|
|
||||||
func onAppBackgrounded() {
|
|
||||||
+ var previouslyPlayingPlayers: [VideoPlayer] = []
|
|
||||||
for videoView in videoViews.allObjects {
|
|
||||||
guard let player = videoView.player else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
- if player.staysActiveInBackground == true {
|
|
||||||
- player.setTracksEnabled(videoView.isInPictureInPicture)
|
|
||||||
- } else if !videoView.isInPictureInPicture {
|
|
||||||
+ if player.isPlaying {
|
|
||||||
player.pointer.pause()
|
|
||||||
+ previouslyPlayingPlayers.append(player)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+ self.previouslyPlayingPlayers = previouslyPlayingPlayers
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Audio Session Management
|
|
||||||
|
|
||||||
internal func setAppropriateAudioSessionOrWarn() {
|
|
||||||
- let audioSession = AVAudioSession.sharedInstance()
|
|
||||||
- var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
|
|
||||||
-
|
|
||||||
- let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
|
|
||||||
- player.isPlaying
|
|
||||||
- }
|
|
||||||
- let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
|
|
||||||
- player.isMuted
|
|
||||||
- }
|
|
||||||
- let needsPiPSupport = videoViews.allObjects.contains { view in
|
|
||||||
- view.allowPictureInPicture
|
|
||||||
- }
|
|
||||||
- let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
|
|
||||||
- player.showNowPlayingNotification
|
|
||||||
- }
|
|
||||||
- // The notification won't be shown if we allow the audio to mix with others
|
|
||||||
- let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
|
|
||||||
- let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
|
|
||||||
- let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
|
|
||||||
-
|
|
||||||
- if shouldAllowMixing {
|
|
||||||
- audioSessionCategoryOptions.insert(.mixWithOthers)
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
|
|
||||||
- do {
|
|
||||||
- try audioSession.setCategory(.playback, mode: .moviePlayback)
|
|
||||||
- } catch {
|
|
||||||
- log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- // Make sure audio session is active if any video is playing
|
|
||||||
- if isAnyPlayerPlaying {
|
|
||||||
- do {
|
|
||||||
- try audioSession.setActive(true)
|
|
||||||
- } catch {
|
|
||||||
- log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
+// let audioSession = AVAudioSession.sharedInstance()
|
|
||||||
+// var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
|
|
||||||
+//
|
|
||||||
+// let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
|
|
||||||
+// player.isPlaying
|
|
||||||
+// }
|
|
||||||
+// let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
|
|
||||||
+// player.isMuted
|
|
||||||
+// }
|
|
||||||
+// let needsPiPSupport = videoViews.allObjects.contains { view in
|
|
||||||
+// view.allowPictureInPicture
|
|
||||||
+// }
|
|
||||||
+// let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
|
|
||||||
+// player.showNowPlayingNotification
|
|
||||||
+// }
|
|
||||||
+// // The notification won't be shown if we allow the audio to mix with others
|
|
||||||
+// let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
|
|
||||||
+// let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
|
|
||||||
+// let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
|
|
||||||
+//
|
|
||||||
+// if shouldAllowMixing {
|
|
||||||
+// audioSessionCategoryOptions.insert(.mixWithOthers)
|
|
||||||
+// }
|
|
||||||
+//
|
|
||||||
+// if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
|
|
||||||
+// do {
|
|
||||||
+// try audioSession.setCategory(.playback, mode: .moviePlayback)
|
|
||||||
+// } catch {
|
|
||||||
+// log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
|
|
||||||
+// }
|
|
||||||
+// }
|
|
||||||
+//
|
|
||||||
+// // Make sure audio session is active if any video is playing
|
|
||||||
+// if isAnyPlayerPlaying {
|
|
||||||
+// do {
|
|
||||||
+// try audioSession.setActive(true)
|
|
||||||
+// } catch {
|
|
||||||
+// log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
|
|
||||||
+// }
|
|
||||||
+// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
index c537a12..e4a918f 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
@@ -16,7 +16,9 @@ public final class VideoModule: Module {
|
|
||||||
View(VideoView.self) {
|
|
||||||
Events(
|
|
||||||
"onPictureInPictureStart",
|
|
||||||
- "onPictureInPictureStop"
|
|
||||||
+ "onPictureInPictureStop",
|
|
||||||
+ "onEnterFullscreen",
|
|
||||||
+ "onExitFullscreen"
|
|
||||||
)
|
|
||||||
|
|
||||||
Prop("player") { (view, player: VideoPlayer?) in
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
|
|
||||||
index 3315b88..733ab1f 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoPlayer.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoPlayer.swift
|
|
||||||
@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
|
|
||||||
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
|
|
||||||
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
func safeEmit<each A: AnyArgument>(event: String, arguments: repeat each A) {
|
|
||||||
if self.appContext != nil {
|
|
||||||
self.emit(event: event, arguments: repeat each arguments)
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
|
||||||
index d289e26..7de8cbf 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
|
||||||
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
|
|
||||||
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?)
|
|
||||||
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool)
|
|
||||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
|
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default implementations for the delegate
|
|
||||||
@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
|
|
||||||
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
|
|
||||||
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {}
|
|
||||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
|
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper used to store WeakReferences to the observer delegate
|
|
||||||
@@ -91,6 +93,7 @@ class VideoPlayerObserver {
|
|
||||||
private var playerVolumeObserver: NSKeyValueObservation?
|
|
||||||
private var playerCurrentItemObserver: NSKeyValueObservation?
|
|
||||||
private var playerIsMutedObserver: NSKeyValueObservation?
|
|
||||||
+ private var playerPeriodicTimeObserver: Any?
|
|
||||||
|
|
||||||
// Current player item observers
|
|
||||||
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
|
||||||
@@ -152,6 +155,9 @@ class VideoPlayerObserver {
|
|
||||||
playerVolumeObserver?.invalidate()
|
|
||||||
playerIsMutedObserver?.invalidate()
|
|
||||||
playerCurrentItemObserver?.invalidate()
|
|
||||||
+ if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver {
|
|
||||||
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
|
|
||||||
@@ -265,23 +271,24 @@ class VideoPlayerObserver {
|
|
||||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate && player.status == .readyToPlay && currentItem?.isPlaybackBufferEmpty != true {
|
|
||||||
status = .readyToPlay
|
|
||||||
} else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
|
|
||||||
- status = .loading
|
|
||||||
+ status = .waitingToPlayAtSpecifiedRate
|
|
||||||
}
|
|
||||||
|
|
||||||
if isPlaying != (player.timeControlStatus == .playing) {
|
|
||||||
isPlaying = player.timeControlStatus == .playing
|
|
||||||
+ addPeriodicTimeObserverIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func onIsBufferEmptyChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange<Bool>) {
|
|
||||||
if playerItem.isPlaybackBufferEmpty {
|
|
||||||
- status = .loading
|
|
||||||
+ status = .playbackBufferEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func onPlayerLikelyToKeepUpChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange<Bool>) {
|
|
||||||
if !playerItem.isPlaybackLikelyToKeepUp && playerItem.isPlaybackBufferEmpty {
|
|
||||||
- status = .loading
|
|
||||||
+ status = .unlikeToKeepUp
|
|
||||||
} else if playerItem.isPlaybackLikelyToKeepUp {
|
|
||||||
status = .readyToPlay
|
|
||||||
}
|
|
||||||
@@ -310,4 +317,28 @@ class VideoPlayerObserver {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) {
|
|
||||||
+ delegates.forEach { delegate in
|
|
||||||
+ delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining)
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ private func addPeriodicTimeObserverIfNeeded() {
|
|
||||||
+ guard self.playerPeriodicTimeObserver == nil, let player = self.player else {
|
|
||||||
+ return
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ if isPlaying {
|
|
||||||
+ // Add the time update listener
|
|
||||||
+ playerPeriodicTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { event in
|
|
||||||
+ guard let duration = player.currentItem?.duration else {
|
|
||||||
+ return
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ let timeRemaining = (duration.seconds - event.seconds).rounded()
|
|
||||||
+ self.onPlayerTimeRemainingChanged(player, timeRemaining)
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
index f4579e4..10c5908 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
|
|
||||||
let onPictureInPictureStart = EventDispatcher()
|
|
||||||
let onPictureInPictureStop = EventDispatcher()
|
|
||||||
+ let onEnterFullscreen = EventDispatcher()
|
|
||||||
+ let onExitFullscreen = EventDispatcher()
|
|
||||||
|
|
||||||
public override var bounds: CGRect {
|
|
||||||
didSet {
|
|
||||||
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
_ playerViewController: AVPlayerViewController,
|
|
||||||
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
|
||||||
) {
|
|
||||||
+ onEnterFullscreen()
|
|
||||||
isFullscreen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
if wasPlaying {
|
|
||||||
self.player?.pointer.play()
|
|
||||||
}
|
|
||||||
+ self.onExitFullscreen()
|
|
||||||
self.isFullscreen = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
|
|
||||||
index aaf4b63..5ff6b7a 100644
|
|
||||||
--- a/node_modules/expo-video/src/VideoPlayer.types.ts
|
|
||||||
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
|
|
||||||
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
|
|
||||||
* Handler for an event emitted when the current media source of the player changes.
|
|
||||||
*/
|
|
||||||
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
|
||||||
+
|
|
||||||
+ timeRemainingChange(timeRemaining: number): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
@@ -160,7 +162,7 @@ export type VideoPlayerEvents = {
|
|
||||||
* - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
|
|
||||||
* - `error`: The player has encountered an error while loading or playing the video.
|
|
||||||
*/
|
|
||||||
-export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
|
|
||||||
+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
|
|
||||||
|
|
||||||
export type VideoSource =
|
|
||||||
| string
|
|
||||||
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
index 29fe5db..e1fbf59 100644
|
|
||||||
--- a/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
+++ b/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
|
|
||||||
* @platform ios 16.0+
|
|
||||||
*/
|
|
||||||
allowsVideoFrameAnalysis?: boolean;
|
|
||||||
+
|
|
||||||
+ onEnterFullscreen?: () => void;
|
|
||||||
+ onExitFullscreen?: () => void;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## uwu woad beawing, do not wemove
|
|
||||||
|
|
||||||
## `expo-video` Patch
|
|
||||||
|
|
||||||
### `onEnterFullScreen`/`onExitFullScreen`
|
|
||||||
|
|
||||||
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
|
||||||
the tin.
|
|
||||||
|
|
||||||
### Removing audio session management
|
|
||||||
|
|
||||||
This patch also removes the audio session management that Expo does on its own, as we handle audio session management
|
|
||||||
ourselves.
|
|
||||||
|
|
||||||
### Pausing/playing on background/foreground
|
|
||||||
|
|
||||||
Instead of handling the pausing/playing of videos in React, we'll handle them here. There's some logic that we do not
|
|
||||||
need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
|
|
||||||
foreground.
|
|
||||||
|
|
||||||
### Additional `statusChange` Events
|
|
||||||
|
|
||||||
`expo-video` uses the `loading` status for a variety of cases where the video is not actually "loading". We're making
|
|
||||||
those status events more specific here, so that we can determine if a video is truly loading or not. These statuses are:
|
|
||||||
|
|
||||||
- `waitingToPlayAtSpecifiedRate`
|
|
||||||
- `unlikelyToKeepUp`
|
|
||||||
- `playbackBufferEmpty`
|
|
||||||
|
|
||||||
It's unlikely we will ever need to pay attention to these statuses, so they are not being include in the TypeScript
|
|
||||||
types.
|
|
||||||
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
|
|||||||
buildSettingsObj.SWIFT_VERSION = '5.0'
|
buildSettingsObj.SWIFT_VERSION = '5.0'
|
||||||
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
||||||
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
||||||
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
|
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
|
||||||
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
|||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export {useDialogControl as usePromptControl} from '#/components/Dialog'
|
export {
|
||||||
|
type DialogControlProps as PromptControlProps,
|
||||||
|
useDialogControl as usePromptControl,
|
||||||
|
} from '#/components/Dialog'
|
||||||
|
|
||||||
const Context = React.createContext<{
|
const Context = React.createContext<{
|
||||||
titleId: string
|
titleId: string
|
||||||
@@ -23,7 +26,7 @@ export function Outer({
|
|||||||
control,
|
control,
|
||||||
testID,
|
testID,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogControlProps
|
||||||
testID?: string
|
testID?: string
|
||||||
}>) {
|
}>) {
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||||
|
|
||||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
|
||||||
let MessageItemEmbed = ({
|
let MessageItemEmbed = ({
|
||||||
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
<PostEmbeds
|
||||||
|
embed={embed}
|
||||||
|
allowNestedQuotes
|
||||||
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
|
||||||
|
})
|
||||||
+64
-16
@@ -37,82 +37,130 @@ export async function dynamicActivate(locale: AppLanguage) {
|
|||||||
switch (locale) {
|
switch (locale) {
|
||||||
case AppLanguage.ca: {
|
case AppLanguage.ca: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ca')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ca'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ca'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.de: {
|
case AppLanguage.de: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesDe})
|
i18n.loadAndActivate({locale, messages: messagesDe})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/de')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/de'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/de'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.es: {
|
case AppLanguage.es: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesEs})
|
i18n.loadAndActivate({locale, messages: messagesEs})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/es')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/es'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/es'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.fi: {
|
case AppLanguage.fi: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesFi})
|
i18n.loadAndActivate({locale, messages: messagesFi})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/fi')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/fi'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/fi'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.fr: {
|
case AppLanguage.fr: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesFr})
|
i18n.loadAndActivate({locale, messages: messagesFr})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/fr')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/fr'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/fr'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.ga: {
|
case AppLanguage.ga: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesGa})
|
i18n.loadAndActivate({locale, messages: messagesGa})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ga')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ga'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ga'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.hi: {
|
case AppLanguage.hi: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesHi})
|
i18n.loadAndActivate({locale, messages: messagesHi})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/hi')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/hi'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/hi'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.id: {
|
case AppLanguage.id: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesId})
|
i18n.loadAndActivate({locale, messages: messagesId})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/id')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/id'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/id'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.it: {
|
case AppLanguage.it: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/it')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/it'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/it'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.ja: {
|
case AppLanguage.ja: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesJa})
|
i18n.loadAndActivate({locale, messages: messagesJa})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ja')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ja'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ja'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.ko: {
|
case AppLanguage.ko: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesKo})
|
i18n.loadAndActivate({locale, messages: messagesKo})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ko')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ko'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ko'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.pt_BR: {
|
case AppLanguage.pt_BR: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/pt')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/pt'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/pt'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.tr: {
|
case AppLanguage.tr: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/tr')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/tr'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/tr'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.uk: {
|
case AppLanguage.uk: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/uk')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/uk'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/uk'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.zh_CN: {
|
case AppLanguage.zh_CN: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/zh'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/zh'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.zh_TW: {
|
case AppLanguage.zh_TW: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/zh'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/zh'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -5576,7 +5576,7 @@ msgstr "Repostado por <0><1/></0>"
|
|||||||
#: src/view/com/posts/FeedItem.tsx:292
|
#: src/view/com/posts/FeedItem.tsx:292
|
||||||
#: src/view/com/posts/FeedItem.tsx:311
|
#: src/view/com/posts/FeedItem.tsx:311
|
||||||
msgid "Reposted by you"
|
msgid "Reposted by you"
|
||||||
msgstr "repostou para você"
|
msgstr "Repostado por você"
|
||||||
|
|
||||||
#: src/view/com/notifications/FeedItem.tsx:184
|
#: src/view/com/notifications/FeedItem.tsx:184
|
||||||
msgid "reposted your post"
|
msgid "reposted your post"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# `#/storage`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Import the correctly scoped store from `#/storage`. Each instance of `Storage`
|
||||||
|
(the base class, not to be used directly), has the following interface:
|
||||||
|
|
||||||
|
- `set([...scope, key], value)`
|
||||||
|
- `get([...scope, key])`
|
||||||
|
- `remove([...scope, key])`
|
||||||
|
- `removeMany([...scope], [...keys])`
|
||||||
|
|
||||||
|
For example, using our `device` store looks like this, since it's scoped to the
|
||||||
|
device (the most base level scope):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { device } from '#/storage';
|
||||||
|
|
||||||
|
device.set(['foobar'], true);
|
||||||
|
device.get(['foobar']);
|
||||||
|
device.remove(['foobar']);
|
||||||
|
device.removeMany([], ['foobar']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
Stores are strongly typed, and when setting a given value, it will need to
|
||||||
|
conform to the schemas defined in `#/storage/schema`. When getting a value, it
|
||||||
|
will be returned to you as the type defined in its schema.
|
||||||
|
|
||||||
|
## Scoped Stores
|
||||||
|
|
||||||
|
Some stores are (or might be) scoped to an account or other identifier. In this
|
||||||
|
case, storage instances are created with type-guards, like this:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AccountSchema = {
|
||||||
|
language: `${string}-${string}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DID = `did:${string}`;
|
||||||
|
|
||||||
|
const account = new Storage<
|
||||||
|
[DID],
|
||||||
|
AccountSchema
|
||||||
|
>({
|
||||||
|
id: 'account',
|
||||||
|
});
|
||||||
|
|
||||||
|
account.set(
|
||||||
|
['did:plc:abc', 'language'],
|
||||||
|
'en-US',
|
||||||
|
);
|
||||||
|
|
||||||
|
const language = account.get([
|
||||||
|
'did:plc:abc',
|
||||||
|
'language',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, if `['did:plc:abc']` is not supplied along with the key of
|
||||||
|
`language`, the `get` will return undefined (and TS will yell at you).
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {beforeEach, expect, jest, test} from '@jest/globals'
|
||||||
|
|
||||||
|
import {Storage} from '#/storage'
|
||||||
|
|
||||||
|
jest.mock('react-native-mmkv', () => ({
|
||||||
|
MMKV: class MMKVMock {
|
||||||
|
_store = new Map()
|
||||||
|
|
||||||
|
set(key: string, value: unknown) {
|
||||||
|
this._store.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
getString(key: string) {
|
||||||
|
return this._store.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string) {
|
||||||
|
return this._store.delete(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
type Schema = {
|
||||||
|
boo: boolean
|
||||||
|
str: string | null
|
||||||
|
num: number
|
||||||
|
obj: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = `account`
|
||||||
|
const store = new Storage<['account'], Schema>({id: 'test'})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.removeMany([scope], ['boo', 'str', 'num', 'obj'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`stores and retrieves data`, () => {
|
||||||
|
store.set([scope, 'boo'], true)
|
||||||
|
store.set([scope, 'str'], 'string')
|
||||||
|
store.set([scope, 'num'], 1)
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(true)
|
||||||
|
expect(store.get([scope, 'str'])).toEqual('string')
|
||||||
|
expect(store.get([scope, 'num'])).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`removes data`, () => {
|
||||||
|
store.set([scope, 'boo'], true)
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(true)
|
||||||
|
store.remove([scope, 'boo'])
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`removes multiple keys at once`, () => {
|
||||||
|
store.set([scope, 'boo'], true)
|
||||||
|
store.set([scope, 'str'], 'string')
|
||||||
|
store.set([scope, 'num'], 1)
|
||||||
|
store.removeMany([scope], ['boo', 'str', 'num'])
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(undefined)
|
||||||
|
expect(store.get([scope, 'str'])).toEqual(undefined)
|
||||||
|
expect(store.get([scope, 'num'])).toEqual(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`concatenates keys`, () => {
|
||||||
|
store.remove([scope, 'str'])
|
||||||
|
store.set([scope, 'str'], 'concat')
|
||||||
|
// @ts-ignore accessing these properties for testing purposes only
|
||||||
|
expect(store.store.getString(`${scope}${store.sep}str`)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`can store falsy values`, () => {
|
||||||
|
store.set([scope, 'str'], null)
|
||||||
|
store.set([scope, 'num'], 0)
|
||||||
|
expect(store.get([scope, 'str'])).toEqual(null)
|
||||||
|
expect(store.get([scope, 'num'])).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`can store objects`, () => {
|
||||||
|
const obj = {foo: true}
|
||||||
|
store.set([scope, 'obj'], obj)
|
||||||
|
expect(store.get([scope, 'obj'])).toEqual(obj)
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {MMKV} from 'react-native-mmkv'
|
||||||
|
|
||||||
|
import {Device} from '#/storage/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic storage class. DO NOT use this directly. Instead, use the exported
|
||||||
|
* storage instances below.
|
||||||
|
*/
|
||||||
|
export class Storage<Scopes extends unknown[], Schema> {
|
||||||
|
protected sep = ':'
|
||||||
|
protected store: MMKV
|
||||||
|
|
||||||
|
constructor({id}: {id: string}) {
|
||||||
|
this.store = new MMKV({id})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value in storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `set([key], value)`
|
||||||
|
* `set([scope, key], value)`
|
||||||
|
*/
|
||||||
|
set<Key extends keyof Schema>(
|
||||||
|
scopes: [...Scopes, Key],
|
||||||
|
data: Schema[Key],
|
||||||
|
): void {
|
||||||
|
// stored as `{ data: <value> }` structure to ease stringification
|
||||||
|
this.store.set(scopes.join(this.sep), JSON.stringify({data}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `get([key])`
|
||||||
|
* `get([scope, key])`
|
||||||
|
*/
|
||||||
|
get<Key extends keyof Schema>(
|
||||||
|
scopes: [...Scopes, Key],
|
||||||
|
): Schema[Key] | undefined {
|
||||||
|
const res = this.store.getString(scopes.join(this.sep))
|
||||||
|
if (!res) return undefined
|
||||||
|
// parsed from storage structure `{ data: <value> }`
|
||||||
|
return JSON.parse(res).data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a value from storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `remove([key])`
|
||||||
|
* `remove([scope, key])`
|
||||||
|
*/
|
||||||
|
remove<Key extends keyof Schema>(scopes: [...Scopes, Key]) {
|
||||||
|
this.store.delete(scopes.join(this.sep))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove many values from the same storage scope by keys
|
||||||
|
*
|
||||||
|
* `removeMany([], [key])`
|
||||||
|
* `removeMany([scope], [key])`
|
||||||
|
*/
|
||||||
|
removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) {
|
||||||
|
keys.forEach(key => this.remove([...scopes, key]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device data that's specific to the device and does not vary based on account
|
||||||
|
*
|
||||||
|
* `device.set([key], true)`
|
||||||
|
*/
|
||||||
|
export const device = new Storage<[], Device>({id: 'device'})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Device data that's specific to the device and does not vary based account
|
||||||
|
*/
|
||||||
|
export type Device = {}
|
||||||
@@ -24,11 +24,14 @@ import Animated, {
|
|||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
|
LayoutAnimationConfig,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useDerivedValue,
|
useDerivedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withRepeat,
|
withRepeat,
|
||||||
withTiming,
|
withTiming,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {
|
import {
|
||||||
@@ -84,7 +87,7 @@ import {GalleryModel} from 'state/models/media/gallery'
|
|||||||
import {State as VideoUploadState} from 'state/queries/video/video'
|
import {State as VideoUploadState} from 'state/queries/video/video'
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
@@ -766,29 +769,36 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{videoUploadState.asset &&
|
<LayoutAnimationConfig skipExiting>
|
||||||
(videoUploadState.status === 'compressing' ? (
|
{(videoUploadState.asset || videoUploadState.video) && (
|
||||||
<VideoTranscodeProgress
|
<Animated.View
|
||||||
asset={videoUploadState.asset}
|
style={[a.w_full, a.mt_xs]}
|
||||||
progress={videoUploadState.progress}
|
entering={native(ZoomIn)}
|
||||||
clear={clearVideo}
|
exiting={native(ZoomOut)}>
|
||||||
/>
|
{videoUploadState.asset &&
|
||||||
) : videoUploadState.video ? (
|
(videoUploadState.status === 'compressing' ? (
|
||||||
<VideoPreview
|
<VideoTranscodeProgress
|
||||||
asset={videoUploadState.asset}
|
asset={videoUploadState.asset}
|
||||||
video={videoUploadState.video}
|
progress={videoUploadState.progress}
|
||||||
setDimensions={updateVideoDimensions}
|
clear={clearVideo}
|
||||||
clear={clearVideo}
|
/>
|
||||||
/>
|
) : videoUploadState.video ? (
|
||||||
) : null)}
|
<VideoPreview
|
||||||
{(videoUploadState.asset || videoUploadState.video) && (
|
asset={videoUploadState.asset}
|
||||||
<SubtitleDialogBtn
|
video={videoUploadState.video}
|
||||||
altText={videoAltText}
|
setDimensions={updateVideoDimensions}
|
||||||
setAltText={setVideoAltText}
|
clear={clearVideo}
|
||||||
captions={captions}
|
/>
|
||||||
setCaptions={setCaptions}
|
) : null)}
|
||||||
/>
|
<SubtitleDialogBtn
|
||||||
)}
|
altText={videoAltText}
|
||||||
|
setAltText={setVideoAltText}
|
||||||
|
captions={captions}
|
||||||
|
setCaptions={setCaptions}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</LayoutAnimationConfig>
|
||||||
</View>
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, {useCallback} from 'react'
|
import React, {useCallback} from 'react'
|
||||||
|
import {Keyboard} from 'react-native'
|
||||||
import {
|
import {
|
||||||
ImagePickerAsset,
|
ImagePickerAsset,
|
||||||
launchImageLibraryAsync,
|
launchImageLibraryAsync,
|
||||||
@@ -10,11 +11,14 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
|
||||||
const VIDEO_MAX_DURATION = 90
|
const VIDEO_MAX_DURATION = 60
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSelectVideo: (video: ImagePickerAsset) => void
|
onSelectVideo: (video: ImagePickerAsset) => void
|
||||||
@@ -26,33 +30,47 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||||
|
const control = Prompt.usePromptControl()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const onPressSelectVideo = useCallback(async () => {
|
const onPressSelectVideo = useCallback(async () => {
|
||||||
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await launchImageLibraryAsync({
|
if (!currentAccount?.emailConfirmed) {
|
||||||
exif: false,
|
Keyboard.dismiss()
|
||||||
mediaTypes: MediaTypeOptions.Videos,
|
control.open()
|
||||||
videoMaxDuration: VIDEO_MAX_DURATION,
|
} else {
|
||||||
quality: 1,
|
const response = await launchImageLibraryAsync({
|
||||||
legacy: true,
|
exif: false,
|
||||||
preferredAssetRepresentationMode:
|
mediaTypes: MediaTypeOptions.Videos,
|
||||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
videoMaxDuration: VIDEO_MAX_DURATION,
|
||||||
})
|
quality: 1,
|
||||||
if (response.assets && response.assets.length > 0) {
|
legacy: true,
|
||||||
try {
|
preferredAssetRepresentationMode:
|
||||||
onSelectVideo(response.assets[0])
|
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||||
} catch (err) {
|
})
|
||||||
if (err instanceof Error) {
|
if (response.assets && response.assets.length > 0) {
|
||||||
setError(err.message)
|
try {
|
||||||
} else {
|
onSelectVideo(response.assets[0])
|
||||||
setError(_(msg`An error occurred while selecting the video`))
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(_(msg`An error occurred while selecting the video`))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectVideo, requestVideoAccessIfNeeded, setError, _])
|
}, [
|
||||||
|
onSelectVideo,
|
||||||
|
requestVideoAccessIfNeeded,
|
||||||
|
setError,
|
||||||
|
_,
|
||||||
|
control,
|
||||||
|
currentAccount?.emailConfirmed,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,6 +89,32 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
|||||||
style={disabled && t.atoms.text_contrast_low}
|
style={disabled && t.atoms.text_contrast_low}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<VerifyEmailPrompt control={control} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {openModal} = useModalControls()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Prompt.Basic
|
||||||
|
control={control}
|
||||||
|
title={_(msg`Verified email required`)}
|
||||||
|
description={_(
|
||||||
|
msg`To upload videos to Bluesky, you must first verify your email.`,
|
||||||
|
)}
|
||||||
|
confirmButtonCta={_(msg`Verify now`)}
|
||||||
|
confirmButtonColor="primary"
|
||||||
|
onConfirm={() => {
|
||||||
|
control.close(() => {
|
||||||
|
openModal({
|
||||||
|
name: 'verify-email',
|
||||||
|
showReminder: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function SubtitleDialogBtn(props: Props) {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.mt_xs]}>
|
<View style={[a.flex_row, a.my_xs]}>
|
||||||
<Button
|
<Button
|
||||||
label={isWeb ? _('Captions & alt text') : _('Alt text')}
|
label={isWeb ? _('Captions & alt text') : _('Alt text')}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {useVideoPlayer, VideoView} from 'expo-video'
|
|||||||
|
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
asset,
|
asset,
|
||||||
@@ -20,10 +22,13 @@ export function VideoPreview({
|
|||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
const player = useVideoPlayer(video.uri, player => {
|
const player = useVideoPlayer(video.uri, player => {
|
||||||
player.loop = true
|
player.loop = true
|
||||||
player.muted = true
|
player.muted = true
|
||||||
player.play()
|
if (!autoplayDisabled) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let aspectRatio = asset.width / asset.height
|
let aspectRatio = asset.width / asset.height
|
||||||
@@ -53,6 +58,11 @@ export function VideoPreview({
|
|||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
/>
|
/>
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
|
{autoplayDisabled && (
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon size={48} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
asset,
|
asset,
|
||||||
@@ -23,6 +25,7 @@ export function VideoPreview({
|
|||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
if (!ref.current) return
|
||||||
@@ -66,17 +69,23 @@ export function VideoPreview({
|
|||||||
{aspectRatio},
|
{aspectRatio},
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
{backgroundColor: 'black'},
|
{backgroundColor: 'black'},
|
||||||
|
a.relative,
|
||||||
]}>
|
]}>
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={video.uri}
|
src={video.uri}
|
||||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||||
autoPlay
|
autoPlay={!autoplayDisabled}
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
|
{autoplayDisabled && (
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon size={48} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export function VideoTranscodeProgress({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.mt_md,
|
|
||||||
t.atoms.bg_contrast_50,
|
t.atoms.bg_contrast_50,
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
|
|||||||
import {Link, TextLink} from '../util/Link'
|
import {Link, TextLink} from '../util/Link'
|
||||||
import {formatCount} from '../util/numeric/format'
|
import {formatCount} from '../util/numeric/format'
|
||||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||||
import {PostEmbeds} from '../util/post-embeds'
|
import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
@@ -363,7 +363,11 @@ let PostThreadItemLoaded = ({
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
{post.embed && (
|
{post.embed && (
|
||||||
<View style={[a.pb_sm]}>
|
<View style={[a.pb_sm]}>
|
||||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
<PostEmbeds
|
||||||
|
embed={post.embed}
|
||||||
|
moderation={moderation}
|
||||||
|
viewContext={PostEmbedViewContext.ThreadHighlighted}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
@@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
{post.embed && (
|
{post.embed && (
|
||||||
<View style={[a.pb_xs]}>
|
<View style={[a.pb_xs]}>
|
||||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
<PostEmbeds
|
||||||
|
embed={post.embed}
|
||||||
|
moderation={moderation}
|
||||||
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
|||||||
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
||||||
import {Link, TextLink} from '../util/Link'
|
import {Link, TextLink} from '../util/Link'
|
||||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||||
import {PostEmbeds} from '../util/post-embeds'
|
import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
@@ -238,7 +238,11 @@ function PostInner({
|
|||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{post.embed ? (
|
{post.embed ? (
|
||||||
<PostEmbeds embed={post.embed} moderation={moderation} />
|
<PostEmbeds
|
||||||
|
embed={post.embed}
|
||||||
|
moderation={moderation}
|
||||||
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {useComposerControls} from '#/state/shell/composer'
|
|||||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||||
import {FeedNameText} from '#/view/com/util/FeedInfoText'
|
import {FeedNameText} from '#/view/com/util/FeedInfoText'
|
||||||
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
|
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
|
||||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
@@ -488,6 +488,7 @@ let PostContent = ({
|
|||||||
embed={postEmbed}
|
embed={postEmbed}
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
onOpen={onOpenEmbed}
|
onOpen={onOpenEmbed}
|
||||||
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,106 +1,219 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native'
|
import {DimensionValue, Pressable, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {clamp} from 'lib/numbers'
|
import {AppBskyEmbedImages} from '@atproto/api'
|
||||||
import {Dimensions} from 'lib/media/types'
|
|
||||||
import * as imageSizes from 'lib/media/image-sizes'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
const MIN_ASPECT_RATIO = 0.33 // 1/3
|
import * as imageSizes from '#/lib/media/image-sizes'
|
||||||
const MAX_ASPECT_RATIO = 10 // 10/1
|
import {Dimensions} from '#/lib/media/types'
|
||||||
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
interface Props {
|
export function useImageAspectRatio({
|
||||||
alt?: string
|
src,
|
||||||
uri: string
|
dimensions,
|
||||||
dimensionsHint?: Dimensions
|
}: {
|
||||||
onPress?: () => void
|
src: string
|
||||||
onLongPress?: () => void
|
dimensions: Dimensions | undefined
|
||||||
onPressIn?: () => void
|
}) {
|
||||||
style?: StyleProp<ViewStyle>
|
const [raw, setAspectRatio] = React.useState<number>(
|
||||||
children?: React.ReactNode
|
dimensions ? calc(dimensions) : 1,
|
||||||
|
)
|
||||||
|
const {isCropped, constrained, max} = React.useMemo(() => {
|
||||||
|
const a34 = 0.75 // max of 3:4 ratio in feeds
|
||||||
|
const constrained = Math.max(raw, a34)
|
||||||
|
const max = Math.max(raw, 0.25) // max of 1:4 in thread
|
||||||
|
const isCropped = raw < constrained
|
||||||
|
return {
|
||||||
|
isCropped,
|
||||||
|
constrained,
|
||||||
|
max,
|
||||||
|
}
|
||||||
|
}, [raw])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let aborted = false
|
||||||
|
if (dimensions) return
|
||||||
|
imageSizes.fetch(src).then(newDim => {
|
||||||
|
if (aborted) return
|
||||||
|
setAspectRatio(calc(newDim))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
aborted = true
|
||||||
|
}
|
||||||
|
}, [dimensions, setAspectRatio, src])
|
||||||
|
|
||||||
|
return {
|
||||||
|
dimensions,
|
||||||
|
raw,
|
||||||
|
constrained,
|
||||||
|
max,
|
||||||
|
isCropped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConstrainedImage({
|
||||||
|
aspectRatio,
|
||||||
|
fullBleed,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
aspectRatio: number
|
||||||
|
fullBleed?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
/**
|
||||||
|
* Computed as a % value to apply as `paddingTop`
|
||||||
|
*/
|
||||||
|
const outerAspectRatio = React.useMemo<DimensionValue>(() => {
|
||||||
|
// capped to square or shorter
|
||||||
|
const ratio = Math.min(1 / aspectRatio, 1)
|
||||||
|
return `${ratio * 100}%`
|
||||||
|
}, [aspectRatio])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.w_full]}>
|
||||||
|
<View style={[a.overflow_hidden, {paddingTop: outerAspectRatio}]}>
|
||||||
|
<View style={[a.absolute, a.inset_0, a.flex_row]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.h_full,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
fullBleed ? a.w_full : {aspectRatio},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoSizedImage({
|
export function AutoSizedImage({
|
||||||
alt,
|
image,
|
||||||
uri,
|
crop = 'constrained',
|
||||||
dimensionsHint,
|
hideBadge,
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
style,
|
}: {
|
||||||
children = null,
|
image: AppBskyEmbedImages.ViewImage
|
||||||
}: Props) {
|
crop?: 'none' | 'square' | 'constrained'
|
||||||
|
hideBadge?: boolean
|
||||||
|
onPress?: () => void
|
||||||
|
onLongPress?: () => void
|
||||||
|
onPressIn?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const [dim, setDim] = React.useState<Dimensions | undefined>(
|
const largeAlt = useLargeAltBadgeEnabled()
|
||||||
dimensionsHint || imageSizes.get(uri),
|
const {
|
||||||
)
|
constrained,
|
||||||
const [aspectRatio, setAspectRatio] = React.useState<number>(
|
max,
|
||||||
dim ? calc(dim) : 1,
|
isCropped: rawIsCropped,
|
||||||
)
|
} = useImageAspectRatio({
|
||||||
React.useEffect(() => {
|
src: image.thumb,
|
||||||
let aborted = false
|
dimensions: image.aspectRatio,
|
||||||
if (dim) {
|
})
|
||||||
return
|
const cropDisabled = crop === 'none'
|
||||||
}
|
const isCropped = rawIsCropped && !cropDisabled
|
||||||
imageSizes.fetch(uri).then(newDim => {
|
const hasAlt = !!image.alt
|
||||||
if (aborted) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDim(newDim)
|
|
||||||
setAspectRatio(calc(newDim))
|
|
||||||
})
|
|
||||||
}, [dim, setDim, setAspectRatio, uri])
|
|
||||||
|
|
||||||
if (onPress || onLongPress || onPressIn) {
|
const contents = (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
style={[a.w_full, a.h_full]}
|
||||||
|
source={image.thumb}
|
||||||
|
accessible={true} // Must set for `accessibilityLabel` to work
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
accessibilityLabel={image.alt}
|
||||||
|
accessibilityHint=""
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(hasAlt || isCropped) && !hideBadge ? (
|
||||||
|
<View
|
||||||
|
accessible={false}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
gap: 3,
|
||||||
|
padding: 3,
|
||||||
|
bottom: a.p_xs.padding,
|
||||||
|
right: a.p_xs.padding,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
largeAlt && [
|
||||||
|
{
|
||||||
|
gap: 4,
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]}>
|
||||||
|
{isCropped && (
|
||||||
|
<Crop
|
||||||
|
fill={t.atoms.text_contrast_high.color}
|
||||||
|
width={largeAlt ? 18 : 12}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasAlt && (
|
||||||
|
<Text style={[a.font_heavy, largeAlt ? a.text_xs : {fontSize: 8}]}>
|
||||||
|
ALT
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cropDisabled) {
|
||||||
return (
|
return (
|
||||||
// disable a11y rule because in this case we want the tags on the image (#1640)
|
|
||||||
// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onLongPress={onLongPress}
|
onLongPress={onLongPress}
|
||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
style={[styles.container, style]}>
|
// alt here is what screen readers actually use
|
||||||
<Image
|
accessibilityLabel={image.alt}
|
||||||
style={[styles.image, {aspectRatio}]}
|
accessibilityHint={_(msg`Tap to view full image`)}
|
||||||
source={uri}
|
style={[
|
||||||
accessible={true} // Must set for `accessibilityLabel` to work
|
a.w_full,
|
||||||
accessibilityIgnoresInvertColors
|
a.rounded_sm,
|
||||||
accessibilityLabel={alt}
|
a.overflow_hidden,
|
||||||
accessibilityHint={_(msg`Tap to view fully`)}
|
t.atoms.bg_contrast_25,
|
||||||
/>
|
{aspectRatio: max},
|
||||||
{children}
|
]}>
|
||||||
|
{contents}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<ConstrainedImage fullBleed={crop === 'square'} aspectRatio={constrained}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
// alt here is what screen readers actually use
|
||||||
|
accessibilityLabel={image.alt}
|
||||||
|
accessibilityHint={_(msg`Tap to view full image`)}
|
||||||
|
style={[a.h_full]}>
|
||||||
|
{contents}
|
||||||
|
</Pressable>
|
||||||
|
</ConstrainedImage>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, style]}>
|
|
||||||
<Image
|
|
||||||
style={[styles.image, {aspectRatio}]}
|
|
||||||
source={{uri}}
|
|
||||||
accessible={true} // Must set for `accessibilityLabel` to work
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
accessibilityLabel={alt}
|
|
||||||
accessibilityHint=""
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function calc(dim: Dimensions) {
|
function calc(dim: Dimensions) {
|
||||||
if (dim.width === 0 || dim.height === 0) {
|
if (dim.width === 0 || dim.height === 0) {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
|
return dim.width / dim.height
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, {ComponentProps, FC} from 'react'
|
import React, {ComponentProps, FC} from 'react'
|
||||||
import {Pressable, StyleSheet, Text, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {AppBskyEmbedImages} from '@atproto/api'
|
import {AppBskyEmbedImages} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
import {atoms as a} from '#/alf'
|
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
type EventFunction = (index: number) => void
|
type EventFunction = (index: number) => void
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ interface GalleryItemProps {
|
|||||||
onPress?: EventFunction
|
onPress?: EventFunction
|
||||||
onLongPress?: EventFunction
|
onLongPress?: EventFunction
|
||||||
onPressIn?: EventFunction
|
onPressIn?: EventFunction
|
||||||
imageStyle: ComponentProps<typeof Image>['style']
|
imageStyle?: ComponentProps<typeof Image>['style']
|
||||||
|
viewContext?: PostEmbedViewContext
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GalleryItem: FC<GalleryItemProps> = ({
|
export const GalleryItem: FC<GalleryItemProps> = ({
|
||||||
@@ -27,57 +29,69 @@ export const GalleryItem: FC<GalleryItemProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
|
viewContext,
|
||||||
}) => {
|
}) => {
|
||||||
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||||
const image = images[index]
|
const image = images[index]
|
||||||
|
const hasAlt = !!image.alt
|
||||||
|
const hideBadges =
|
||||||
|
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
return (
|
return (
|
||||||
<View style={a.flex_1}>
|
<View style={a.flex_1}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress ? () => onPress(index) : undefined}
|
onPress={onPress ? () => onPress(index) : undefined}
|
||||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||||
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
||||||
style={a.flex_1}
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.rounded_xs,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
imageStyle,
|
||||||
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={image.alt || _(msg`Image`)}
|
accessibilityLabel={image.alt || _(msg`Image`)}
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<Image
|
<Image
|
||||||
source={{uri: image.thumb}}
|
source={{uri: image.thumb}}
|
||||||
style={[a.flex_1, a.rounded_xs, imageStyle]}
|
style={[a.flex_1]}
|
||||||
accessible={true}
|
accessible={true}
|
||||||
accessibilityLabel={image.alt}
|
accessibilityLabel={image.alt}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{image.alt === '' ? null : (
|
{hasAlt && !hideBadges ? (
|
||||||
<View style={styles.altContainer}>
|
<View
|
||||||
|
accessible={false}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
gap: 3,
|
||||||
|
padding: 3,
|
||||||
|
bottom: a.p_xs.padding,
|
||||||
|
right: a.p_xs.padding,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
largeAltBadge && [
|
||||||
|
{
|
||||||
|
gap: 4,
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]}>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.alt, largeAltBadge && a.text_xs]}
|
style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
|
||||||
accessible={false}>
|
|
||||||
ALT
|
ALT
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
altContainer: {
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
|
||||||
borderRadius: 6,
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
paddingVertical: 3,
|
|
||||||
position: 'absolute',
|
|
||||||
// Related to margin/gap hack. This keeps the alt label in the same position
|
|
||||||
// on all platforms
|
|
||||||
right: isWeb ? 8 : 5,
|
|
||||||
bottom: isWeb ? 8 : 5,
|
|
||||||
},
|
|
||||||
alt: {
|
|
||||||
color: 'white',
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import {AppBskyEmbedImages} from '@atproto/api'
|
import {AppBskyEmbedImages} from '@atproto/api'
|
||||||
|
|
||||||
|
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
|
||||||
|
import {atoms as a, useBreakpoints} from '#/alf'
|
||||||
import {GalleryItem} from './Gallery'
|
import {GalleryItem} from './Gallery'
|
||||||
import {isWeb} from 'platform/detection'
|
|
||||||
|
|
||||||
interface ImageLayoutGridProps {
|
interface ImageLayoutGridProps {
|
||||||
images: AppBskyEmbedImages.ViewImage[]
|
images: AppBskyEmbedImages.ViewImage[]
|
||||||
@@ -10,13 +12,25 @@ interface ImageLayoutGridProps {
|
|||||||
onLongPress?: (index: number) => void
|
onLongPress?: (index: number) => void
|
||||||
onPressIn?: (index: number) => void
|
onPressIn?: (index: number) => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
viewContext?: PostEmbedViewContext
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
|
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const gap =
|
||||||
|
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
? gtMobile
|
||||||
|
? a.gap_xs
|
||||||
|
: a.gap_2xs
|
||||||
|
: gtMobile
|
||||||
|
? a.gap_sm
|
||||||
|
: a.gap_xs
|
||||||
|
const count = props.images.length
|
||||||
|
const aspectRatio = count === 2 ? 2 : count === 3 ? 1.5 : 1
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
<View style={styles.container}>
|
<View style={[gap, {aspectRatio}]}>
|
||||||
<ImageLayoutGridInner {...props} />
|
<ImageLayoutGridInner {...props} gap={gap} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -27,36 +41,39 @@ interface ImageLayoutGridInnerProps {
|
|||||||
onPress?: (index: number) => void
|
onPress?: (index: number) => void
|
||||||
onLongPress?: (index: number) => void
|
onLongPress?: (index: number) => void
|
||||||
onPressIn?: (index: number) => void
|
onPressIn?: (index: number) => void
|
||||||
|
viewContext?: PostEmbedViewContext
|
||||||
|
gap: {gap: number}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
||||||
|
const gap = props.gap
|
||||||
const count = props.images.length
|
const count = props.images.length
|
||||||
|
|
||||||
switch (count) {
|
switch (count) {
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<View style={styles.flexRow}>
|
<View style={[a.flex_1, a.flex_row, gap]}>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={0} imageStyle={styles.image} />
|
<GalleryItem {...props} index={0} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={1} imageStyle={styles.image} />
|
<GalleryItem {...props} index={1} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<View style={styles.flexRow}>
|
<View style={[a.flex_1, a.flex_row, gap]}>
|
||||||
<View style={styles.threeSingle}>
|
<View style={{flex: 2}}>
|
||||||
<GalleryItem {...props} index={0} imageStyle={styles.image} />
|
<GalleryItem {...props} index={0} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.threeDouble}>
|
<View style={[a.flex_1, gap]}>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={1} imageStyle={styles.image} />
|
<GalleryItem {...props} index={1} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={2} imageStyle={styles.image} />
|
<GalleryItem {...props} index={2} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
|||||||
case 4:
|
case 4:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={styles.flexRow}>
|
<View style={[a.flex_row, gap]}>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={0} imageStyle={styles.image} />
|
<GalleryItem {...props} index={0} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={1} imageStyle={styles.image} />
|
<GalleryItem {...props} index={1} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.flexRow}>
|
<View style={[a.flex_row, gap]}>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={2} imageStyle={styles.image} />
|
<GalleryItem {...props} index={2} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.smallItem}>
|
<View style={[a.flex_1, {aspectRatio: 1}]}>
|
||||||
<GalleryItem {...props} index={3} imageStyle={styles.image} />
|
<GalleryItem {...props} index={3} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@@ -88,39 +105,3 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// On web we use margin to calculate gap, as aspectRatio does not properly size
|
|
||||||
// all images on web. On native though we cannot rely on margin, since the
|
|
||||||
// negative margin interferes with the swipe controls on pagers.
|
|
||||||
// https://github.com/facebook/yoga/issues/1418
|
|
||||||
// https://github.com/bluesky-social/social-app/issues/2601
|
|
||||||
const IMAGE_GAP = 5
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: isWeb
|
|
||||||
? {
|
|
||||||
marginHorizontal: -IMAGE_GAP / 2,
|
|
||||||
marginVertical: -IMAGE_GAP / 2,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
gap: IMAGE_GAP,
|
|
||||||
},
|
|
||||||
flexRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: isWeb ? undefined : IMAGE_GAP,
|
|
||||||
},
|
|
||||||
smallItem: {flex: 1, aspectRatio: 1},
|
|
||||||
image: isWeb
|
|
||||||
? {
|
|
||||||
margin: IMAGE_GAP / 2,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
threeSingle: {
|
|
||||||
flex: 2,
|
|
||||||
aspectRatio: isWeb ? 1 : undefined,
|
|
||||||
},
|
|
||||||
threeDouble: {
|
|
||||||
flex: 1,
|
|
||||||
gap: isWeb ? undefined : IMAGE_GAP,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ let RepostButton = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
requireAuth(() => dialogControl.open())
|
requireAuth(() => dialogControl.open())
|
||||||
}}
|
}}
|
||||||
|
onLongPress={() => {
|
||||||
|
requireAuth(() => onQuote())
|
||||||
|
}}
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {InfoCircleIcon} from 'lib/icons'
|
|||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {precacheProfile} from 'state/queries/profile'
|
import {precacheProfile} from 'state/queries/profile'
|
||||||
import {ComposerOptsQuote} from 'state/shell/composer'
|
import {ComposerOptsQuote} from 'state/shell/composer'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, useBreakpoints} from '#/alf'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
|
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
|
||||||
@@ -41,17 +41,20 @@ import {Link} from '../Link'
|
|||||||
import {PostMeta} from '../PostMeta'
|
import {PostMeta} from '../PostMeta'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {PostEmbeds} from '.'
|
import {PostEmbeds} from '.'
|
||||||
|
import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
||||||
|
|
||||||
export function MaybeQuoteEmbed({
|
export function MaybeQuoteEmbed({
|
||||||
embed,
|
embed,
|
||||||
onOpen,
|
onOpen,
|
||||||
style,
|
style,
|
||||||
allowNestedQuotes,
|
allowNestedQuotes,
|
||||||
|
viewContext,
|
||||||
}: {
|
}: {
|
||||||
embed: AppBskyEmbedRecord.View
|
embed: AppBskyEmbedRecord.View
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
allowNestedQuotes?: boolean
|
allowNestedQuotes?: boolean
|
||||||
|
viewContext?: QuoteEmbedViewContext
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -67,6 +70,7 @@ export function MaybeQuoteEmbed({
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
style={style}
|
style={style}
|
||||||
allowNestedQuotes={allowNestedQuotes}
|
allowNestedQuotes={allowNestedQuotes}
|
||||||
|
viewContext={viewContext}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
|
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
|
||||||
@@ -113,12 +117,14 @@ function QuoteEmbedModerated({
|
|||||||
onOpen,
|
onOpen,
|
||||||
style,
|
style,
|
||||||
allowNestedQuotes,
|
allowNestedQuotes,
|
||||||
|
viewContext,
|
||||||
}: {
|
}: {
|
||||||
viewRecord: AppBskyEmbedRecord.ViewRecord
|
viewRecord: AppBskyEmbedRecord.ViewRecord
|
||||||
postRecord: AppBskyFeedPost.Record
|
postRecord: AppBskyFeedPost.Record
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
allowNestedQuotes?: boolean
|
allowNestedQuotes?: boolean
|
||||||
|
viewContext?: QuoteEmbedViewContext
|
||||||
}) {
|
}) {
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const moderation = React.useMemo(() => {
|
const moderation = React.useMemo(() => {
|
||||||
@@ -144,6 +150,7 @@ function QuoteEmbedModerated({
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
style={style}
|
style={style}
|
||||||
allowNestedQuotes={allowNestedQuotes}
|
allowNestedQuotes={allowNestedQuotes}
|
||||||
|
viewContext={viewContext}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -154,18 +161,21 @@ export function QuoteEmbed({
|
|||||||
onOpen,
|
onOpen,
|
||||||
style,
|
style,
|
||||||
allowNestedQuotes,
|
allowNestedQuotes,
|
||||||
|
viewContext,
|
||||||
}: {
|
}: {
|
||||||
quote: ComposerOptsQuote
|
quote: ComposerOptsQuote
|
||||||
moderation?: ModerationDecision
|
moderation?: ModerationDecision
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
allowNestedQuotes?: boolean
|
allowNestedQuotes?: boolean
|
||||||
|
viewContext?: QuoteEmbedViewContext
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const itemUrip = new AtUri(quote.uri)
|
const itemUrip = new AtUri(quote.uri)
|
||||||
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
|
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
|
||||||
const itemTitle = `Post by ${quote.author.handle}`
|
const itemTitle = `Post by ${quote.author.handle}`
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
const richText = React.useMemo(
|
const richText = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -197,6 +207,7 @@ export function QuoteEmbed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [quote.embeds, allowNestedQuotes])
|
}, [quote.embeds, allowNestedQuotes])
|
||||||
|
const isImagesEmbed = AppBskyEmbedImages.isView(embed)
|
||||||
|
|
||||||
const onBeforePress = React.useCallback(() => {
|
const onBeforePress = React.useCallback(() => {
|
||||||
precacheProfile(queryClient, quote.author)
|
precacheProfile(queryClient, quote.author)
|
||||||
@@ -226,15 +237,43 @@ export function QuoteEmbed({
|
|||||||
{moderation ? (
|
{moderation ? (
|
||||||
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
|
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
|
||||||
) : null}
|
) : null}
|
||||||
{richText ? (
|
|
||||||
<RichText
|
{viewContext === QuoteEmbedViewContext.FeedEmbedRecordWithMedia &&
|
||||||
value={richText}
|
isImagesEmbed ? (
|
||||||
style={a.text_md}
|
<View style={[a.flex_row, a.gap_md]}>
|
||||||
numberOfLines={20}
|
{embed && (
|
||||||
disableLinks
|
<View style={[{width: gtMobile ? 100 : 80}]}>
|
||||||
/>
|
<PostEmbeds
|
||||||
) : null}
|
embed={embed}
|
||||||
{embed && <PostEmbeds embed={embed} moderation={moderation} />}
|
moderation={moderation}
|
||||||
|
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{richText ? (
|
||||||
|
<View style={[a.flex_1, a.pt_xs]}>
|
||||||
|
<RichText
|
||||||
|
value={richText}
|
||||||
|
style={a.text_md}
|
||||||
|
numberOfLines={20}
|
||||||
|
disableLinks
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{richText ? (
|
||||||
|
<RichText
|
||||||
|
value={richText}
|
||||||
|
style={a.text_md}
|
||||||
|
numberOfLines={20}
|
||||||
|
disableLinks
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{embed && <PostEmbeds embed={embed} moderation={moderation} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {useCallback, useEffect, useId, useState} from 'react'
|
import React, {useCallback, useEffect, useId, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {VideoPlayerStatus} from 'expo-video'
|
import {PlayerError, VideoPlayerStatus} from 'expo-video'
|
||||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -68,7 +68,9 @@ function InnerWrapper({embed}: Props) {
|
|||||||
useActiveVideoNative()
|
useActiveVideoNative()
|
||||||
const viewId = useId()
|
const viewId = useId()
|
||||||
|
|
||||||
const [playerStatus, setPlayerStatus] = useState<VideoPlayerStatus>('loading')
|
const [playerStatus, setPlayerStatus] = useState<
|
||||||
|
VideoPlayerStatus | 'switching'
|
||||||
|
>('loading')
|
||||||
const [isMuted, setIsMuted] = useState(player.muted)
|
const [isMuted, setIsMuted] = useState(player.muted)
|
||||||
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
||||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||||
@@ -77,6 +79,14 @@ function InnerWrapper({embed}: Props) {
|
|||||||
isActive &&
|
isActive &&
|
||||||
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
|
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
|
||||||
playerStatus === 'loading')
|
playerStatus === 'loading')
|
||||||
|
const isSwitching = playerStatus === 'switching'
|
||||||
|
const showOverlay = !isActive || isLoading || isSwitching
|
||||||
|
|
||||||
|
// send error up to error boundary
|
||||||
|
const [error, setError] = useState<Error | PlayerError | null>(null)
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
@@ -92,10 +102,10 @@ function InnerWrapper({embed}: Props) {
|
|||||||
)
|
)
|
||||||
const statusSub = player.addListener(
|
const statusSub = player.addListener(
|
||||||
'statusChange',
|
'statusChange',
|
||||||
(status, _oldStatus, error) => {
|
(status, _oldStatus, playerError) => {
|
||||||
setPlayerStatus(status)
|
setPlayerStatus(status)
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
throw error
|
setError(playerError ?? new Error('Unknown player error'))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -124,6 +134,7 @@ function InnerWrapper({embed}: Props) {
|
|||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setPlayerStatus('switching')
|
||||||
player.muted = true
|
player.muted = true
|
||||||
if (player.playing) {
|
if (player.playing) {
|
||||||
player.pause()
|
player.pause()
|
||||||
@@ -142,48 +153,47 @@ function InnerWrapper({embed}: Props) {
|
|||||||
setIsFullscreen={setIsFullscreen}
|
setIsFullscreen={setIsFullscreen}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isActive || isLoading ? (
|
<View
|
||||||
<View
|
style={[
|
||||||
style={[
|
{
|
||||||
{
|
position: 'absolute',
|
||||||
position: 'absolute',
|
top: 0,
|
||||||
top: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
display: showOverlay ? 'flex' : 'none',
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<Image
|
<Image
|
||||||
source={{uri: embed.thumbnail}}
|
source={{uri: embed.thumbnail}}
|
||||||
alt={embed.alt}
|
alt={embed.alt}
|
||||||
style={a.flex_1}
|
style={a.flex_1}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
style={[a.absolute, a.inset_0]}
|
style={[a.absolute, a.inset_0]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setActiveSource(embed.playlist, viewId)
|
setActiveSource(embed.playlist, viewId)
|
||||||
}}
|
}}
|
||||||
label={_(msg`Play video`)}
|
label={_(msg`Play video`)}
|
||||||
color="secondary">
|
color="secondary">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
a.p_xs,
|
a.p_xs,
|
||||||
a.absolute,
|
a.absolute,
|
||||||
{top: 'auto', left: 'auto'},
|
{top: 'auto', left: 'auto'},
|
||||||
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
||||||
]}>
|
]}>
|
||||||
<Loader size="2xl" style={{color: 'white'}} />
|
<Loader size="2xl" style={{color: 'white'}} />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<PlayButtonIcon />
|
<PlayButtonIcon />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
</VisibilityView>
|
</VisibilityView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {
|
import {
|
||||||
HLSUnsupportedError,
|
HLSUnsupportedError,
|
||||||
VideoEmbedInnerWeb,
|
VideoEmbedInnerWeb,
|
||||||
|
VideoNotFoundError,
|
||||||
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {ErrorBoundary} from '../ErrorBoundary'
|
import {ErrorBoundary} from '../ErrorBoundary'
|
||||||
@@ -152,23 +154,26 @@ function ViewportObserver({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
|
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
|
||||||
const isHLS = error instanceof HLSUnsupportedError
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
let showRetryButton = true
|
||||||
|
let text = null
|
||||||
|
|
||||||
|
if (error instanceof VideoNotFoundError) {
|
||||||
|
text = _(msg`Video not found.`)
|
||||||
|
} else if (error instanceof HLSUnsupportedError) {
|
||||||
|
showRetryButton = false
|
||||||
|
text = _(
|
||||||
|
msg`Your browser does not support the video format. Please try a different browser.`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
text = _(msg`An error occurred while loading the video. Please try again.`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoFallback.Container>
|
<VideoFallback.Container>
|
||||||
<VideoFallback.Text>
|
<VideoFallback.Text>{text}</VideoFallback.Text>
|
||||||
{isHLS ? (
|
{showRetryButton && <VideoFallback.RetryButton onPress={retry} />}
|
||||||
<Trans>
|
|
||||||
Your browser does not support the video format. Please try a
|
|
||||||
different browser.
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
An error occurred while loading the video. Please try again later.
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</VideoFallback.Text>
|
|
||||||
{!isHLS && <VideoFallback.RetryButton onPress={retry} />}
|
|
||||||
</VideoFallback.Container>
|
</VideoFallback.Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ export function VideoEmbedInnerNative({
|
|||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
nativeControls={isFullscreen}
|
nativeControls={isFullscreen}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
onEnterFullscreen={() => {
|
onFullscreenEnter={() => {
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
||||||
PlatformInfo.setAudioActive(true)
|
PlatformInfo.setAudioActive(true)
|
||||||
player.muted = false
|
player.muted = false
|
||||||
setIsFullscreen(true)
|
setIsFullscreen(true)
|
||||||
}}
|
}}
|
||||||
onExitFullscreen={() => {
|
onFullscreenExit={() => {
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||||
PlatformInfo.setAudioActive(false)
|
PlatformInfo.setAudioActive(false)
|
||||||
player.muted = true
|
player.muted = true
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export function VideoEmbedInnerWeb({
|
|||||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||||
const figId = useId()
|
const figId = useId()
|
||||||
|
|
||||||
|
// send error up to error boundary
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,12 +44,25 @@ export function VideoEmbedInnerWeb({
|
|||||||
// initial value, later on it's managed by Controls
|
// initial value, later on it's managed by Controls
|
||||||
hls.autoLevelCapping = 0
|
hls.autoLevelCapping = 0
|
||||||
|
|
||||||
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
|
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
|
||||||
if (data.subtitleTracks.length > 0) {
|
if (data.subtitleTracks.length > 0) {
|
||||||
setHasSubtitleTrack(true)
|
setHasSubtitleTrack(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
if (data.fatal) {
|
||||||
|
if (
|
||||||
|
data.details === 'manifestLoadError' &&
|
||||||
|
data.response?.code === 404
|
||||||
|
) {
|
||||||
|
setError(new VideoNotFoundError())
|
||||||
|
} else {
|
||||||
|
setError(data.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
hlsRef.current = undefined
|
hlsRef.current = undefined
|
||||||
hls.detachMedia()
|
hls.detachMedia()
|
||||||
@@ -104,3 +123,9 @@ export class HLSUnsupportedError extends Error {
|
|||||||
super('HLS is not supported')
|
super('HLS is not supported')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class VideoNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Video not found')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ export function Controls({
|
|||||||
// autoplay/pause based on visibility
|
// autoplay/pause based on visibility
|
||||||
const autoplayDisabled = useAutoplayDisabled()
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active && !autoplayDisabled) {
|
if (active) {
|
||||||
if (onScreen) {
|
if (onScreen) {
|
||||||
play()
|
if (!autoplayDisabled) play()
|
||||||
} else {
|
} else {
|
||||||
pause()
|
pause()
|
||||||
}
|
}
|
||||||
@@ -151,10 +151,11 @@ export function Controls({
|
|||||||
const onPressEmptySpace = useCallback(() => {
|
const onPressEmptySpace = useCallback(() => {
|
||||||
if (!focused) {
|
if (!focused) {
|
||||||
drawFocus()
|
drawFocus()
|
||||||
|
if (autoplayDisabled) play()
|
||||||
} else {
|
} else {
|
||||||
togglePlayPause()
|
togglePlayPause()
|
||||||
}
|
}
|
||||||
}, [togglePlayPause, drawFocus, focused])
|
}, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
|
||||||
|
|
||||||
const onPressPlayPause = useCallback(() => {
|
const onPressPlayPause = useCallback(() => {
|
||||||
drawFocus()
|
drawFocus()
|
||||||
@@ -240,7 +241,8 @@ export function Controls({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const showControls =
|
const showControls =
|
||||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
((focused || autoplayDisabled) && !playing) ||
|
||||||
|
(interactingViaKeypress ? hasFocus : hovered)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -273,7 +275,10 @@ export function Controls({
|
|||||||
? msg`Pause video`
|
? msg`Pause video`
|
||||||
: msg`Play video`,
|
: msg`Play video`,
|
||||||
)}
|
)}
|
||||||
style={[a.flex_1, web({cursor: showCursor ? 'pointer' : 'none'})]}
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
|
||||||
|
]}
|
||||||
onPress={onPressEmptySpace}
|
onPress={onPressEmptySpace}
|
||||||
/>
|
/>
|
||||||
{!showControls && !focused && duration > 0 && (
|
{!showControls && !focused && duration > 0 && (
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
InteractionManager,
|
InteractionManager,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
|
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||||
@@ -34,8 +32,11 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
|
|||||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||||
|
import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
||||||
import {VideoEmbed} from './VideoEmbed'
|
import {VideoEmbed} from './VideoEmbed'
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
|
||||||
type Embed =
|
type Embed =
|
||||||
| AppBskyEmbedRecord.View
|
| AppBskyEmbedRecord.View
|
||||||
| AppBskyEmbedImages.View
|
| AppBskyEmbedImages.View
|
||||||
@@ -50,15 +51,16 @@ export function PostEmbeds({
|
|||||||
onOpen,
|
onOpen,
|
||||||
style,
|
style,
|
||||||
allowNestedQuotes,
|
allowNestedQuotes,
|
||||||
|
viewContext,
|
||||||
}: {
|
}: {
|
||||||
embed?: Embed
|
embed?: Embed
|
||||||
moderation?: ModerationDecision
|
moderation?: ModerationDecision
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
allowNestedQuotes?: boolean
|
allowNestedQuotes?: boolean
|
||||||
|
viewContext?: PostEmbedViewContext
|
||||||
}) {
|
}) {
|
||||||
const {openLightbox} = useLightboxControls()
|
const {openLightbox} = useLightboxControls()
|
||||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
|
||||||
|
|
||||||
// quote post with media
|
// quote post with media
|
||||||
// =
|
// =
|
||||||
@@ -69,8 +71,17 @@ export function PostEmbeds({
|
|||||||
embed={embed.media}
|
embed={embed.media}
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
|
viewContext={viewContext}
|
||||||
|
/>
|
||||||
|
<MaybeQuoteEmbed
|
||||||
|
embed={embed.record}
|
||||||
|
onOpen={onOpen}
|
||||||
|
viewContext={
|
||||||
|
viewContext === PostEmbedViewContext.Feed
|
||||||
|
? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -124,27 +135,26 @@ export function PostEmbeds({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
const {alt, thumb, aspectRatio} = images[0]
|
const image = images[0]
|
||||||
return (
|
return (
|
||||||
<ContentHider modui={moderation?.ui('contentMedia')}>
|
<ContentHider modui={moderation?.ui('contentMedia')}>
|
||||||
<View style={[styles.container, style]}>
|
<View style={[styles.container, style]}>
|
||||||
<AutoSizedImage
|
<AutoSizedImage
|
||||||
alt={alt}
|
crop={
|
||||||
uri={thumb}
|
viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||||
dimensionsHint={aspectRatio}
|
? 'none'
|
||||||
|
: viewContext ===
|
||||||
|
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
? 'square'
|
||||||
|
: 'constrained'
|
||||||
|
}
|
||||||
|
image={image}
|
||||||
onPress={() => _openLightbox(0)}
|
onPress={() => _openLightbox(0)}
|
||||||
onPressIn={() => onPressIn(0)}
|
onPressIn={() => onPressIn(0)}
|
||||||
style={a.rounded_sm}>
|
hideBadge={
|
||||||
{alt === '' ? null : (
|
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
<View style={styles.altContainer}>
|
}
|
||||||
<Text
|
/>
|
||||||
style={[styles.alt, largeAltBadge && a.text_xs]}
|
|
||||||
accessible={false}>
|
|
||||||
ALT
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</AutoSizedImage>
|
|
||||||
</View>
|
</View>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
@@ -157,6 +167,7 @@ export function PostEmbeds({
|
|||||||
images={embed.images}
|
images={embed.images}
|
||||||
onPress={_openLightbox}
|
onPress={_openLightbox}
|
||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
|
viewContext={viewContext}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export enum PostEmbedViewContext {
|
||||||
|
ThreadHighlighted = 'ThreadHighlighted',
|
||||||
|
Feed = 'Feed',
|
||||||
|
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum QuoteEmbedViewContext {
|
||||||
|
FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ export function AccessibilitySettingsScreen({}: Props) {
|
|||||||
<View style={[pal.view, styles.toggleCard]}>
|
<View style={[pal.view, styles.toggleCard]}>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
type="default-light"
|
type="default-light"
|
||||||
label={_(msg`Disable autoplay for GIFs`)}
|
label={_(msg`Disable autoplay for videos and GIFs`)}
|
||||||
labelType="lg"
|
labelType="lg"
|
||||||
isSelected={autoplayDisabled}
|
isSelected={autoplayDisabled}
|
||||||
onPress={() => setAutoplayDisabled(!autoplayDisabled)}
|
onPress={() => setAutoplayDisabled(!autoplayDisabled)}
|
||||||
|
|||||||
@@ -9917,12 +9917,7 @@ caniuse-api@^3.0.0:
|
|||||||
lodash.memoize "^4.1.2"
|
lodash.memoize "^4.1.2"
|
||||||
lodash.uniq "^4.5.0"
|
lodash.uniq "^4.5.0"
|
||||||
|
|
||||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
|
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001587:
|
||||||
version "1.0.30001655"
|
|
||||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
|
|
||||||
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
|
|
||||||
|
|
||||||
caniuse-lite@^1.0.30001587:
|
|
||||||
version "1.0.30001655"
|
version "1.0.30001655"
|
||||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
|
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
|
||||||
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
|
integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
|
||||||
@@ -12419,10 +12414,9 @@ expo-updates@~0.25.14:
|
|||||||
ignore "^5.3.1"
|
ignore "^5.3.1"
|
||||||
resolve-from "^5.0.0"
|
resolve-from "^5.0.0"
|
||||||
|
|
||||||
expo-video@^1.2.4:
|
"expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz":
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.2.4.tgz#787342aded4295a1b6864f59227d178b93e1bb53"
|
resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz#57f61a72f41b86e5a587d9782d32bd32487a551e"
|
||||||
integrity sha512-pBK9mt7vYAbuPQjCSQxHQ7xrNjbmRheJep7JIStEg57O183/JRfP2blKuXniiSt1HBdZYPdoQnGRa3jGMXB9pg==
|
|
||||||
|
|
||||||
expo-web-browser@~13.0.3:
|
expo-web-browser@~13.0.3:
|
||||||
version "13.0.3"
|
version "13.0.3"
|
||||||
@@ -19053,6 +19047,11 @@ react-native-keyboard-controller@^1.12.1:
|
|||||||
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f"
|
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f"
|
||||||
integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==
|
integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==
|
||||||
|
|
||||||
|
react-native-mmkv@^2.12.2:
|
||||||
|
version "2.12.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.12.2.tgz#4bba0f5f04e2cf222494cce3a9794ba6a4894dee"
|
||||||
|
integrity sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg==
|
||||||
|
|
||||||
react-native-pager-view@6.2.3:
|
react-native-pager-view@6.2.3:
|
||||||
version "6.2.3"
|
version "6.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"
|
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"
|
||||||
|
|||||||
Reference in New Issue
Block a user