Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
@@ -9,7 +9,7 @@
|
|||||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.13.1",
|
"@atproto/api": "0.13.6",
|
||||||
"@preact/preset-vite": "^2.8.2",
|
"@preact/preset-vite": "^2.8.2",
|
||||||
"@vitejs/plugin-legacy": "^5.3.2",
|
"@vitejs/plugin-legacy": "^5.3.2",
|
||||||
"preact": "^10.4.8",
|
"preact": "^10.4.8",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
@@ -14,6 +15,7 @@ import {ComponentChildren, h} from 'preact'
|
|||||||
import {useMemo} from 'preact/hooks'
|
import {useMemo} from 'preact/hooks'
|
||||||
|
|
||||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||||
|
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||||
import starterPackIcon from '../../assets/starterPack.svg'
|
import starterPackIcon from '../../assets/starterPack.svg'
|
||||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||||
import {getRkey} from '../utils'
|
import {getRkey} from '../utils'
|
||||||
@@ -160,7 +162,12 @@ export function Embed({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 4: Record with media
|
// Case 4: Video
|
||||||
|
if (AppBskyEmbedVideo.isView(content)) {
|
||||||
|
return <VideoEmbed content={content} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 5: Record with media
|
||||||
if (
|
if (
|
||||||
AppBskyEmbedRecordWithMedia.isView(content) &&
|
AppBskyEmbedRecordWithMedia.isView(content) &&
|
||||||
AppBskyEmbedRecord.isViewRecord(content.record.record)
|
AppBskyEmbedRecord.isViewRecord(content.record.record)
|
||||||
@@ -354,6 +361,31 @@ function GenericWithImageEmbed({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// just the thumbnail and a play button
|
||||||
|
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||||
|
let aspectRatio = 1
|
||||||
|
|
||||||
|
if (content.aspectRatio) {
|
||||||
|
const {width, height} = content.aspectRatio
|
||||||
|
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full overflow-hidden rounded-lg aspect-square"
|
||||||
|
style={{aspectRatio: `${aspectRatio} / 1`}}>
|
||||||
|
<img
|
||||||
|
src={content.thumbnail}
|
||||||
|
alt={content.alt}
|
||||||
|
className="object-cover size-full"
|
||||||
|
/>
|
||||||
|
<div className="size-24 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 flex items-center justify-center">
|
||||||
|
<img src={playIcon} className="object-cover size-3/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StarterPackEmbed({
|
function StarterPackEmbed({
|
||||||
content,
|
content,
|
||||||
}: {
|
}: {
|
||||||
@@ -410,3 +442,7 @@ function getStarterPackHref(
|
|||||||
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
|
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
|
||||||
return `/starter-pack/${handleOrDid}/${rkey}`
|
return `/starter-pack/${handleOrDid}/${rkey}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(num: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(num, max))
|
||||||
|
}
|
||||||
|
|||||||
+9
-9
@@ -20,15 +20,15 @@
|
|||||||
"@jridgewell/gen-mapping" "^0.3.5"
|
"@jridgewell/gen-mapping" "^0.3.5"
|
||||||
"@jridgewell/trace-mapping" "^0.3.24"
|
"@jridgewell/trace-mapping" "^0.3.24"
|
||||||
|
|
||||||
"@atproto/api@0.13.1":
|
"@atproto/api@0.13.6":
|
||||||
version "0.13.1"
|
version "0.13.6"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
|
||||||
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
|
integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.1"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
"@atproto/syntax" "^0.3.0"
|
"@atproto/syntax" "^0.3.0"
|
||||||
"@atproto/xrpc" "^0.6.0"
|
"@atproto/xrpc" "^0.6.1"
|
||||||
await-lock "^2.2.2"
|
await-lock "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
@@ -59,10 +59,10 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
||||||
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
||||||
|
|
||||||
"@atproto/xrpc@^0.6.0":
|
"@atproto/xrpc@^0.6.1":
|
||||||
version "0.6.0"
|
version "0.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
|
||||||
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
|
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/lexicon" "^0.4.1"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ index 473f964..f37aff9 100644
|
|||||||
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
|
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
|
||||||
override val name = "playToEnd"
|
override val name = "playToEnd"
|
||||||
}
|
}
|
||||||
|
|
||||||
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
|
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
|
||||||
+ override val name = "timeRemainingChange"
|
+ override val name = "timeRemainingChange"
|
||||||
+ override val arguments = arrayOf(timeRemaining)
|
+ override val arguments = arrayOf(timeRemaining)
|
||||||
@@ -32,10 +32,10 @@ index 9905e13..47342ff 100644
|
|||||||
setTimeBarInteractive(requireLinearPlayback)
|
setTimeBarInteractive(requireLinearPlayback)
|
||||||
+ setShowSubtitleButton(true)
|
+ setShowSubtitleButton(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
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)
|
||||||
@@ -144,7 +144,7 @@ index ec3da2a..5a1397a 100644
|
|||||||
+ "onEnterFullscreen",
|
+ "onEnterFullscreen",
|
||||||
+ "onExitFullscreen"
|
+ "onExitFullscreen"
|
||||||
)
|
)
|
||||||
|
|
||||||
Prop("player") { view: VideoView, player: VideoPlayer ->
|
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
|
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
|
index 58f00af..5ad8237 100644
|
||||||
@@ -152,7 +152,7 @@ index 58f00af..5ad8237 100644
|
|||||||
+++ b/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 @@
|
@@ -1,5 +1,6 @@
|
||||||
package expo.modules.video
|
package expo.modules.video
|
||||||
|
|
||||||
+import ProgressTracker
|
+import ProgressTracker
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
@@ -162,18 +162,18 @@ index 58f00af..5ad8237 100644
|
|||||||
.setLooper(context.mainLooper)
|
.setLooper(context.mainLooper)
|
||||||
.build()
|
.build()
|
||||||
+ var progressTracker: ProgressTracker? = null
|
+ var progressTracker: ProgressTracker? = null
|
||||||
|
|
||||||
val serviceConnection = PlaybackServiceConnection(WeakReference(player))
|
val serviceConnection = PlaybackServiceConnection(WeakReference(player))
|
||||||
|
|
||||||
var playing by IgnoreSameSet(false) { new, old ->
|
var playing by IgnoreSameSet(false) { new, old ->
|
||||||
sendEvent(PlayerEvent.IsPlayingChanged(new, old))
|
sendEvent(PlayerEvent.IsPlayingChanged(new, old))
|
||||||
+ addOrRemoveProgressTracker()
|
+ addOrRemoveProgressTracker()
|
||||||
}
|
}
|
||||||
|
|
||||||
var uncommittedSource: VideoSource? = source
|
var uncommittedSource: VideoSource? = source
|
||||||
@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
+ this.progressTracker?.remove()
|
+ this.progressTracker?.remove()
|
||||||
+ this.progressTracker = null
|
+ this.progressTracker = null
|
||||||
@@ -184,7 +184,7 @@ index 58f00af..5ad8237 100644
|
|||||||
@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||||
listeners.removeAll { it.get() == videoPlayerListener }
|
listeners.removeAll { it.get() == videoPlayerListener }
|
||||||
}
|
}
|
||||||
|
|
||||||
- private fun sendEvent(event: PlayerEvent) {
|
- private fun sendEvent(event: PlayerEvent) {
|
||||||
+ fun sendEvent(event: PlayerEvent) {
|
+ fun sendEvent(event: PlayerEvent) {
|
||||||
// Emits to the native listeners
|
// Emits to the native listeners
|
||||||
@@ -224,7 +224,7 @@ index a951d80..3932535 100644
|
|||||||
val onPictureInPictureStop by EventDispatcher<Unit>()
|
val onPictureInPictureStop by EventDispatcher<Unit>()
|
||||||
+ val onEnterFullscreen by EventDispatcher()
|
+ val onEnterFullscreen by EventDispatcher()
|
||||||
+ val onExitFullscreen by EventDispatcher()
|
+ val onExitFullscreen by EventDispatcher()
|
||||||
|
|
||||||
var willEnterPiP: Boolean = false
|
var willEnterPiP: Boolean = false
|
||||||
var isInFullscreen: Boolean = false
|
var isInFullscreen: Boolean = false
|
||||||
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
||||||
@@ -234,7 +234,7 @@ index a951d80..3932535 100644
|
|||||||
+ onEnterFullscreen(mapOf())
|
+ onEnterFullscreen(mapOf())
|
||||||
isInFullscreen = true
|
isInFullscreen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
@@ -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)
|
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
|
||||||
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
|
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
|
||||||
@@ -242,9 +242,9 @@ index a951d80..3932535 100644
|
|||||||
+ this.onExitFullscreen(mapOf())
|
+ this.onExitFullscreen(mapOf())
|
||||||
isInFullscreen = false
|
isInFullscreen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||||
index a09fcfe..5eac9e5 100644
|
index a09fcfe..46cbae7 100644
|
||||||
--- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
--- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||||
+++ b/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 = {
|
@@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
|
||||||
@@ -256,6 +256,15 @@ index a09fcfe..5eac9e5 100644
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Describes the current status of the player.
|
* 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
|
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
|
index cb9ca6d..ed8bb7e 100644
|
||||||
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
||||||
@@ -270,8 +279,21 @@ index cb9ca6d..ed8bb7e 100644
|
|||||||
}
|
}
|
||||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
//# sourceMappingURL=VideoView.types.d.ts.map
|
||||||
\ No newline at end of file
|
\ 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
|
diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift
|
||||||
index 094a8b0..3f00525 100644
|
index 094a8b0..16e7081 100644
|
||||||
--- a/node_modules/expo-video/ios/VideoManager.swift
|
--- a/node_modules/expo-video/ios/VideoManager.swift
|
||||||
+++ b/node_modules/expo-video/ios/VideoManager.swift
|
+++ b/node_modules/expo-video/ios/VideoManager.swift
|
||||||
@@ -12,6 +12,7 @@ class VideoManager {
|
@@ -12,6 +12,7 @@ class VideoManager {
|
||||||
@@ -409,7 +431,7 @@ index c537a12..e4a918f 100644
|
|||||||
+ "onEnterFullscreen",
|
+ "onEnterFullscreen",
|
||||||
+ "onExitFullscreen"
|
+ "onExitFullscreen"
|
||||||
)
|
)
|
||||||
|
|
||||||
Prop("player") { (view, player: VideoPlayer?) in
|
Prop("player") { (view, player: VideoPlayer?) in
|
||||||
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
|
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
|
||||||
index 3315b88..733ab1f 100644
|
index 3315b88..733ab1f 100644
|
||||||
@@ -418,7 +440,7 @@ index 3315b88..733ab1f 100644
|
|||||||
@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
|
@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
|
||||||
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
|
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
|
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
|
||||||
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
|
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
|
||||||
+ }
|
+ }
|
||||||
@@ -427,7 +449,7 @@ index 3315b88..733ab1f 100644
|
|||||||
if self.appContext != nil {
|
if self.appContext != nil {
|
||||||
self.emit(event: event, arguments: repeat each arguments)
|
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
|
diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||||
index d289e26..ea4d96f 100644
|
index d289e26..7de8cbf 100644
|
||||||
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||||
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||||
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
|
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
|
||||||
@@ -436,7 +458,7 @@ index d289e26..ea4d96f 100644
|
|||||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
|
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
|
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default implementations for the delegate
|
// Default implementations for the delegate
|
||||||
@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
|
@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
|
||||||
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
|
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
|
||||||
@@ -444,14 +466,14 @@ index d289e26..ea4d96f 100644
|
|||||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
|
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
|
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrapper used to store WeakReferences to the observer delegate
|
// Wrapper used to store WeakReferences to the observer delegate
|
||||||
@@ -91,6 +93,7 @@ class VideoPlayerObserver {
|
@@ -91,6 +93,7 @@ class VideoPlayerObserver {
|
||||||
private var playerVolumeObserver: NSKeyValueObservation?
|
private var playerVolumeObserver: NSKeyValueObservation?
|
||||||
private var playerCurrentItemObserver: NSKeyValueObservation?
|
private var playerCurrentItemObserver: NSKeyValueObservation?
|
||||||
private var playerIsMutedObserver: NSKeyValueObservation?
|
private var playerIsMutedObserver: NSKeyValueObservation?
|
||||||
+ private var playerPeriodicTimeObserver: Any?
|
+ private var playerPeriodicTimeObserver: Any?
|
||||||
|
|
||||||
// Current player item observers
|
// Current player item observers
|
||||||
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
||||||
@@ -152,6 +155,9 @@ class VideoPlayerObserver {
|
@@ -152,6 +155,9 @@ class VideoPlayerObserver {
|
||||||
@@ -462,16 +484,36 @@ index d289e26..ea4d96f 100644
|
|||||||
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
|
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
|
||||||
+ }
|
+ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
|
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
|
||||||
@@ -270,6 +276,7 @@ class VideoPlayerObserver {
|
@@ -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) {
|
if isPlaying != (player.timeControlStatus == .playing) {
|
||||||
isPlaying = player.timeControlStatus == .playing
|
isPlaying = player.timeControlStatus == .playing
|
||||||
+ addPeriodicTimeObserverIfNeeded()
|
+ 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 {
|
@@ -310,4 +317,28 @@ class VideoPlayerObserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,12 +548,12 @@ index f4579e4..10c5908 100644
|
|||||||
--- a/node_modules/expo-video/ios/VideoView.swift
|
--- a/node_modules/expo-video/ios/VideoView.swift
|
||||||
+++ b/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 {
|
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||||
|
|
||||||
let onPictureInPictureStart = EventDispatcher()
|
let onPictureInPictureStart = EventDispatcher()
|
||||||
let onPictureInPictureStop = EventDispatcher()
|
let onPictureInPictureStop = EventDispatcher()
|
||||||
+ let onEnterFullscreen = EventDispatcher()
|
+ let onEnterFullscreen = EventDispatcher()
|
||||||
+ let onExitFullscreen = EventDispatcher()
|
+ let onExitFullscreen = EventDispatcher()
|
||||||
|
|
||||||
public override var bounds: CGRect {
|
public override var bounds: CGRect {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||||
@@ -521,7 +563,7 @@ index f4579e4..10c5908 100644
|
|||||||
+ onEnterFullscreen()
|
+ onEnterFullscreen()
|
||||||
isFullscreen = true
|
isFullscreen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
self.player?.pointer.play()
|
self.player?.pointer.play()
|
||||||
@@ -531,7 +573,7 @@ index f4579e4..10c5908 100644
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
|
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||||
index aaf4b63..f438196 100644
|
index aaf4b63..5ff6b7a 100644
|
||||||
--- a/node_modules/expo-video/src/VideoPlayer.types.ts
|
--- a/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||||
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
|
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||||
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
|
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
|
||||||
@@ -541,8 +583,17 @@ index aaf4b63..f438196 100644
|
|||||||
+
|
+
|
||||||
+ timeRemainingChange(timeRemaining: number): 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
|
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
||||||
index 29fe5db..e1fbf59 100644
|
index 29fe5db..e1fbf59 100644
|
||||||
--- a/node_modules/expo-video/src/VideoView.types.ts
|
--- a/node_modules/expo-video/src/VideoView.types.ts
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## `expo-video` Patch
|
## `expo-video` Patch
|
||||||
|
|
||||||
### `onEnterFullScreen`/`onExitFullScreen`
|
### `onEnterFullScreen`/`onExitFullScreen`
|
||||||
|
|
||||||
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
||||||
the tin.
|
the tin.
|
||||||
|
|
||||||
@@ -16,3 +17,15 @@ ourselves.
|
|||||||
Instead of handling the pausing/playing of videos in React, we'll handle them here. There's some logic that we do not
|
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
|
need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
|
||||||
foreground.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {
|
||||||
|
AppBskyEmbedExternal,
|
||||||
|
AppBskyEmbedImages,
|
||||||
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streamlined MediaPreview component which just handles images, gifs, and videos
|
||||||
|
*/
|
||||||
|
export function Embed({
|
||||||
|
embed,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
embed?:
|
||||||
|
| AppBskyEmbedImages.View
|
||||||
|
| AppBskyEmbedRecordWithMedia.View
|
||||||
|
| AppBskyEmbedExternal.View
|
||||||
|
| AppBskyEmbedVideo.View
|
||||||
|
| {[k: string]: unknown}
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
|
||||||
|
|
||||||
|
if (AppBskyEmbedImages.isView(media)) {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
{media.images.map(image => (
|
||||||
|
<ImageItem
|
||||||
|
key={image.thumb}
|
||||||
|
thumbnail={image.thumb}
|
||||||
|
alt={image.alt}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
|
||||||
|
let url: URL | undefined
|
||||||
|
try {
|
||||||
|
url = new URL(embed.external.uri)
|
||||||
|
} catch {}
|
||||||
|
if (url) {
|
||||||
|
const {success} = parseTenorGif(url)
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
<GifItem
|
||||||
|
thumbnail={embed.external.thumb}
|
||||||
|
alt={embed.external.title}
|
||||||
|
/>
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedVideo.isView(embed)) {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outer({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
return <View style={[a.flex_row, a.gap_xs, style]}>{children}</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageItem({
|
||||||
|
thumbnail,
|
||||||
|
alt,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
thumbnail: string
|
||||||
|
alt?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
|
||||||
|
<Image
|
||||||
|
key={thumbnail}
|
||||||
|
source={{uri: thumbnail}}
|
||||||
|
style={[a.flex_1, a.rounded_xs]}
|
||||||
|
contentFit="cover"
|
||||||
|
accessible={true}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
accessibilityHint={alt}
|
||||||
|
accessibilityLabel=""
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
|
||||||
|
return (
|
||||||
|
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon size={24} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.altContainer}>
|
||||||
|
<Text style={styles.alt}>
|
||||||
|
<Trans>GIF</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ImageItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoItem({
|
||||||
|
thumbnail,
|
||||||
|
alt,
|
||||||
|
}: {
|
||||||
|
thumbnail?: string
|
||||||
|
alt?: string
|
||||||
|
}) {
|
||||||
|
if (!thumbnail) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{backgroundColor: 'black'},
|
||||||
|
a.flex_1,
|
||||||
|
{aspectRatio: 1, maxWidth: 100},
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
<PlayButtonIcon size={24} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon size={24} />
|
||||||
|
</View>
|
||||||
|
</ImageItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
altContainer: {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
position: 'absolute',
|
||||||
|
right: 5,
|
||||||
|
bottom: 5,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 7,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ export const sizes = {
|
|||||||
md: 20,
|
md: 20,
|
||||||
lg: 24,
|
lg: 24,
|
||||||
xl: 28,
|
xl: 28,
|
||||||
|
'2xl': 32,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommonSVGProps(props: Props) {
|
export function useCommonSVGProps(props: Props) {
|
||||||
|
|||||||
@@ -14,19 +14,18 @@ import {
|
|||||||
} from '#/components/moderation/LabelsOnMeDialog'
|
} from '#/components/moderation/LabelsOnMeDialog'
|
||||||
|
|
||||||
export function LabelsOnMe({
|
export function LabelsOnMe({
|
||||||
details,
|
type,
|
||||||
labels,
|
labels,
|
||||||
size,
|
size,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
details: {did: string} | {uri: string; cid: string}
|
type: 'account' | 'content'
|
||||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||||
size?: ButtonSize
|
size?: ButtonSize
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const isAccount = 'did' in details
|
|
||||||
const control = useLabelsOnMeDialogControl()
|
const control = useLabelsOnMeDialogControl()
|
||||||
|
|
||||||
if (!labels || !currentAccount) {
|
if (!labels || !currentAccount) {
|
||||||
@@ -39,7 +38,7 @@ export function LabelsOnMe({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, style]}>
|
<View style={[a.flex_row, style]}>
|
||||||
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
<LabelsOnMeDialog control={control} labels={labels} type={type} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@@ -51,7 +50,7 @@ export function LabelsOnMe({
|
|||||||
}}>
|
}}>
|
||||||
<ButtonIcon position="left" icon={CircleInfo} />
|
<ButtonIcon position="left" icon={CircleInfo} />
|
||||||
<ButtonText style={[a.leading_snug]}>
|
<ButtonText style={[a.leading_snug]}>
|
||||||
{isAccount ? (
|
{type === 'account' ? (
|
||||||
<Plural
|
<Plural
|
||||||
value={labels.length}
|
value={labels.length}
|
||||||
one="# label has been placed on this account"
|
one="# label has been placed on this account"
|
||||||
@@ -82,6 +81,6 @@ export function LabelsOnMyPost({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
|
<LabelsOnMe type="content" labels={post.labels} size="tiny" style={style} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useLabelSubject} from '#/lib/moderation'
|
||||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
@@ -18,21 +19,13 @@ import {InlineLinkText} from '#/components/Link'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {Divider} from '../Divider'
|
import {Divider} from '../Divider'
|
||||||
import {Loader} from '../Loader'
|
import {Loader} from '../Loader'
|
||||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
|
||||||
|
|
||||||
type Subject =
|
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||||
| {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
did: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LabelsOnMeDialogProps {
|
export interface LabelsOnMeDialogProps {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
subject: Subject
|
|
||||||
labels: ComAtprotoLabelDefs.Label[]
|
labels: ComAtprotoLabelDefs.Label[]
|
||||||
|
type: 'account' | 'content'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||||
@@ -51,8 +44,8 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
|||||||
const [appealingLabel, setAppealingLabel] = React.useState<
|
const [appealingLabel, setAppealingLabel] = React.useState<
|
||||||
ComAtprotoLabelDefs.Label | undefined
|
ComAtprotoLabelDefs.Label | undefined
|
||||||
>(undefined)
|
>(undefined)
|
||||||
const {subject, labels} = props
|
const {labels} = props
|
||||||
const isAccount = 'did' in subject
|
const isAccount = props.type === 'account'
|
||||||
const containsSelfLabel = React.useMemo(
|
const containsSelfLabel = React.useMemo(
|
||||||
() => labels.some(l => l.src === currentAccount?.did),
|
() => labels.some(l => l.src === currentAccount?.did),
|
||||||
[currentAccount?.did, labels],
|
[currentAccount?.did, labels],
|
||||||
@@ -68,7 +61,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
|||||||
{appealingLabel ? (
|
{appealingLabel ? (
|
||||||
<AppealForm
|
<AppealForm
|
||||||
label={appealingLabel}
|
label={appealingLabel}
|
||||||
subject={subject}
|
|
||||||
control={props.control}
|
control={props.control}
|
||||||
onPressBack={() => setAppealingLabel(undefined)}
|
onPressBack={() => setAppealingLabel(undefined)}
|
||||||
/>
|
/>
|
||||||
@@ -188,12 +180,10 @@ function Label({
|
|||||||
|
|
||||||
function AppealForm({
|
function AppealForm({
|
||||||
label,
|
label,
|
||||||
subject,
|
|
||||||
control,
|
control,
|
||||||
onPressBack,
|
onPressBack,
|
||||||
}: {
|
}: {
|
||||||
label: ComAtprotoLabelDefs.Label
|
label: ComAtprotoLabelDefs.Label
|
||||||
subject: Subject
|
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
onPressBack: () => void
|
onPressBack: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -201,6 +191,7 @@ function AppealForm({
|
|||||||
const {labeler, strings} = useLabelInfo(label)
|
const {labeler, strings} = useLabelInfo(label)
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const [details, setDetails] = React.useState('')
|
const [details, setDetails] = React.useState('')
|
||||||
|
const {subject} = useLabelSubject({label})
|
||||||
const isAccountReport = 'did' in subject
|
const isAccountReport = 'did' in subject
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const sourceName = labeler
|
const sourceName = labeler
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||||
|
|
||||||
|
export function PlayButtonIcon({size = 44}: {size?: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
backgroundColor: t.palette.primary_500,
|
||||||
|
width: size + 16,
|
||||||
|
height: size + 16,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<PlayIcon height={size} width={size} style={{color: 'white'}} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -271,7 +271,12 @@ export class FeedTuner {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
this.seenUris.add(item.post.uri)
|
// Reposting a reply elevates it to top-level, so its parent/root won't be displayed.
|
||||||
|
// Disable in-thread dedupe for this case since we don't want to miss them later.
|
||||||
|
const disableDedupe = slice.isReply && slice.isRepost
|
||||||
|
if (!disableDedupe) {
|
||||||
|
this.seenUris.add(item.post.uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// - The count is going down and is 1 less than a multiple of 100
|
// - The count is going down and is 1 less than a multiple of 100
|
||||||
export function decideShouldRoll(isSet: boolean, count: number) {
|
export function decideShouldRoll(isSet: boolean, count: number) {
|
||||||
let shouldRoll = false
|
let shouldRoll = false
|
||||||
if (!isSet && count === 0) {
|
if (!isSet && count === 1) {
|
||||||
shouldRoll = true
|
shouldRoll = true
|
||||||
} else if (count > 0 && count < 1000) {
|
} else if (count > 1 && count < 1000) {
|
||||||
shouldRoll = true
|
shouldRoll = true
|
||||||
} else if (count > 0) {
|
} else if (count > 0) {
|
||||||
const mod = count % 100
|
const mod = count % 100
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
AppBskyLabelerDefs,
|
AppBskyLabelerDefs,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
|
ComAtprotoLabelDefs,
|
||||||
InterpretedLabelValueDefinition,
|
InterpretedLabelValueDefinition,
|
||||||
LABELS,
|
LABELS,
|
||||||
ModerationCause,
|
ModerationCause,
|
||||||
@@ -82,3 +84,34 @@ export function isLabelerSubscribed(
|
|||||||
}
|
}
|
||||||
return modOpts.prefs.labelers.find(l => l.did === labeler)
|
return modOpts.prefs.labelers.find(l => l.did === labeler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Subject =
|
||||||
|
| {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
did: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLabelSubject({label}: {label: ComAtprotoLabelDefs.Label}): {
|
||||||
|
subject: Subject
|
||||||
|
} {
|
||||||
|
return React.useMemo(() => {
|
||||||
|
const {cid, uri} = label
|
||||||
|
if (cid) {
|
||||||
|
return {
|
||||||
|
subject: {
|
||||||
|
uri,
|
||||||
|
cid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
subject: {
|
||||||
|
did: uri,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [label])
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export function useReportOptions(): ReportOptions {
|
|||||||
other,
|
other,
|
||||||
],
|
],
|
||||||
post: [
|
post: [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONMISLEADING,
|
||||||
|
title: _(msg`Misleading Post`),
|
||||||
|
description: _(msg`Impersonation, misinformation, or false claims`),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
reason: ComAtprotoModerationDefs.REASONSPAM,
|
reason: ComAtprotoModerationDefs.REASONSPAM,
|
||||||
title: _(msg`Spam`),
|
title: _(msg`Spam`),
|
||||||
|
|||||||
@@ -226,11 +226,11 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
|
|||||||
let secondsActive = 0
|
let secondsActive = 0
|
||||||
if (lastActive != null) {
|
if (lastActive != null) {
|
||||||
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
||||||
|
lastActive = null
|
||||||
|
logEvent('state:background:sampled', {
|
||||||
|
secondsActive,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
lastActive = null
|
|
||||||
logEvent('state:background:sampled', {
|
|
||||||
secondsActive,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ msgstr "{0, plural, one {# repostagem} other {# repostagens}}"
|
|||||||
|
|
||||||
#: src/components/KnownFollowers.tsx:179
|
#: src/components/KnownFollowers.tsx:179
|
||||||
#~ msgid "{0, plural, one {and # other} other {and # others}}"
|
#~ msgid "{0, plural, one {and # other} other {and # others}}"
|
||||||
#~ msgstr "{0, plural, one {e # outro} other {e # outros}"
|
#~ msgstr "{0, plural, one {e # outro} other {e # outros}}"
|
||||||
|
|
||||||
#: src/components/ProfileHoverCard/index.web.tsx:398
|
#: src/components/ProfileHoverCard/index.web.tsx:398
|
||||||
#: src/screens/Profile/Header/Metrics.tsx:23
|
#: src/screens/Profile/Header/Metrics.tsx:23
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
||||||
import {LayoutAnimation, View} from 'react-native'
|
import {LayoutAnimation, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyEmbedImages,
|
|
||||||
AppBskyEmbedRecordWithMedia,
|
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyRichtextFacet,
|
AppBskyRichtextFacet,
|
||||||
AtUri,
|
AtUri,
|
||||||
@@ -22,12 +20,12 @@ import {
|
|||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {usePostQuery} from '#/state/queries/post'
|
import {usePostQuery} from '#/state/queries/post'
|
||||||
import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
|
|
||||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as MediaPreview from '#/components/MediaPreview'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
@@ -160,13 +158,6 @@ export function MessageInputEmbed({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const images = AppBskyEmbedImages.isView(post.embed)
|
|
||||||
? post.embed.images
|
|
||||||
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
|
||||||
AppBskyEmbedImages.isView(post.embed.media)
|
|
||||||
? post.embed.media.images
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -202,9 +193,7 @@ export function MessageInputEmbed({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{images && images?.length > 0 && (
|
<MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
|
||||||
<ImageHorzList images={images} style={a.mt_xs} />
|
|
||||||
)}
|
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ let ProfileHeaderShell = ({
|
|||||||
style={[a.px_lg, a.py_xs]}
|
style={[a.px_lg, a.py_xs]}
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
|
<LabelsOnMe type="account" labels={profile.labels} />
|
||||||
) : (
|
) : (
|
||||||
<ProfileHeaderAlerts moderation={moderation} />
|
<ProfileHeaderAlerts moderation={moderation} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -123,12 +123,16 @@ export function useUploadVideo({
|
|||||||
blobRef,
|
blobRef,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError: useCallback(() => {
|
onError: useCallback(
|
||||||
dispatch({
|
error => {
|
||||||
type: 'SetError',
|
logger.error('Error processing video', {safeMessage: error})
|
||||||
error: _(msg`Video failed to process`),
|
dispatch({
|
||||||
})
|
type: 'SetError',
|
||||||
}, [_]),
|
error: _(msg`Video failed to process`),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[_],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const {mutate: onVideoCompressed} = useUploadVideoMutation({
|
const {mutate: onVideoCompressed} = useUploadVideoMutation({
|
||||||
@@ -140,6 +144,7 @@ export function useUploadVideo({
|
|||||||
setJobId(response.jobId)
|
setJobId(response.jobId)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
|
logger.error('Error uploading video', {safeMessage: e})
|
||||||
if (e instanceof ServerError) {
|
if (e instanceof ServerError) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
@@ -171,6 +176,7 @@ export function useUploadVideo({
|
|||||||
onVideoCompressed(video)
|
onVideoCompressed(video)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
|
logger.error('Error uploading video', {safeMessage: e})
|
||||||
if (e instanceof VideoTooLargeError) {
|
if (e instanceof VideoTooLargeError) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
@@ -250,7 +256,7 @@ const useUploadStatusQuery = ({
|
|||||||
throw new Error('Job completed, but did not return a blob')
|
throw new Error('Job completed, but did not return a blob')
|
||||||
onSuccess(status.blob)
|
onSuccess(status.blob)
|
||||||
} else if (status.state === 'JOB_STATE_FAILED') {
|
} else if (status.state === 'JOB_STATE_FAILED') {
|
||||||
throw new Error('Job failed to process')
|
throw new Error(status.error ?? 'Job failed to process')
|
||||||
}
|
}
|
||||||
onStatusChange(status)
|
onStatusChange(status)
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import {
|
|||||||
// @ts-expect-error no type definition
|
// @ts-expect-error no type definition
|
||||||
import ProgressCircle from 'react-native-progress/Circle'
|
import ProgressCircle from 'react-native-progress/Circle'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
Easing,
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
@@ -221,7 +224,12 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
|
if (
|
||||||
|
graphemeLength > 0 ||
|
||||||
|
!gallery.isEmpty ||
|
||||||
|
extGif ||
|
||||||
|
videoUploadState.status !== 'idle'
|
||||||
|
) {
|
||||||
closeAllDialogs()
|
closeAllDialogs()
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
discardPromptControl.open()
|
discardPromptControl.open()
|
||||||
@@ -235,6 +243,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
closeAllDialogs,
|
closeAllDialogs,
|
||||||
discardPromptControl,
|
discardPromptControl,
|
||||||
onClose,
|
onClose,
|
||||||
|
videoUploadState.status,
|
||||||
])
|
])
|
||||||
|
|
||||||
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
||||||
@@ -329,7 +338,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
richtext.text.trim().length === 0 &&
|
richtext.text.trim().length === 0 &&
|
||||||
gallery.isEmpty &&
|
gallery.isEmpty &&
|
||||||
!extLink &&
|
!extLink &&
|
||||||
!quote
|
!quote &&
|
||||||
|
videoUploadState.status === 'idle'
|
||||||
) {
|
) {
|
||||||
setError(_(msg`Did you want to say anything?`))
|
setError(_(msg`Did you want to say anything?`))
|
||||||
return
|
return
|
||||||
@@ -595,7 +605,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<View style={[styles.postBtnWrapper]}>
|
||||||
<LabelsBtn
|
<LabelsBtn
|
||||||
labels={labels}
|
labels={labels}
|
||||||
onChange={setLabels}
|
onChange={setLabels}
|
||||||
@@ -631,7 +641,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -999,6 +1009,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
},
|
},
|
||||||
|
postBtnWrapper: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
errorLine: {
|
errorLine: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -1080,6 +1094,29 @@ function ToolbarWrapper({
|
|||||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const progress = state.jobStatus?.progress
|
||||||
|
? state.jobStatus.progress / 100
|
||||||
|
: state.progress
|
||||||
|
let wheelProgress = progress === 0 || progress === 1 ? 0.33 : progress
|
||||||
|
|
||||||
|
const rotate = useDerivedValue(() => {
|
||||||
|
if (progress === 0 || progress >= 0.99) {
|
||||||
|
return withRepeat(
|
||||||
|
withTiming(360, {
|
||||||
|
duration: 2500,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [{rotateZ: `${rotate.value}deg`}],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let text = ''
|
let text = ''
|
||||||
|
|
||||||
@@ -1098,26 +1135,22 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100
|
|
||||||
let progress =
|
|
||||||
state.status === 'compressing' || state.status === 'uploading'
|
|
||||||
? state.progress
|
|
||||||
: 100
|
|
||||||
|
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
text = _('Error')
|
text = _('Error')
|
||||||
progress = 100
|
wheelProgress = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||||
<ProgressCircle
|
<Animated.View style={[animatedStyle]}>
|
||||||
size={30}
|
<ProgressCircle
|
||||||
borderWidth={1}
|
size={30}
|
||||||
borderColor={t.atoms.border_contrast_low.borderColor}
|
borderWidth={1}
|
||||||
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
|
borderColor={t.atoms.border_contrast_low.borderColor}
|
||||||
progress={progress}
|
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
|
||||||
/>
|
progress={wheelProgress}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
||||||
</ToolbarWrapper>
|
</ToolbarWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, {useEffect, useRef} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {ImagePickerAsset} from 'expo-image-picker'
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
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 * 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'
|
||||||
|
|
||||||
@@ -19,6 +22,7 @@ export function VideoPreview({
|
|||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
if (!ref.current) return
|
||||||
@@ -32,11 +36,19 @@ export function VideoPreview({
|
|||||||
},
|
},
|
||||||
{signal},
|
{signal},
|
||||||
)
|
)
|
||||||
|
ref.current.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
Toast.show(_(msg`Could not process your video`))
|
||||||
|
clear()
|
||||||
|
},
|
||||||
|
{signal},
|
||||||
|
)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
}, [setDimensions])
|
}, [setDimensions, _, clear])
|
||||||
|
|
||||||
let aspectRatio = asset.width / asset.height
|
let aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
AppBskyActorDefs,
|
||||||
AppBskyEmbedExternal,
|
|
||||||
AppBskyEmbedImages,
|
|
||||||
AppBskyEmbedRecordWithMedia,
|
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyGraphFollow,
|
AppBskyGraphFollow,
|
||||||
@@ -31,7 +28,6 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {FeedNotification} from '#/state/queries/notifications/feed'
|
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
@@ -58,11 +54,11 @@ import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/com
|
|||||||
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
|
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
|
||||||
import {StarterPack} from '#/components/icons/StarterPack'
|
import {StarterPack} from '#/components/icons/StarterPack'
|
||||||
import {Link as NewLink} from '#/components/Link'
|
import {Link as NewLink} from '#/components/Link'
|
||||||
|
import * as MediaPreview from '#/components/MediaPreview'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||||
import {FeedSourceCard} from '../feeds/FeedSourceCard'
|
import {FeedSourceCard} from '../feeds/FeedSourceCard'
|
||||||
import {Post} from '../post/Post'
|
import {Post} from '../post/Post'
|
||||||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
|
||||||
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 {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
@@ -728,49 +724,14 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
|||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
if (post && AppBskyFeedPost.isRecord(post?.record)) {
|
if (post && AppBskyFeedPost.isRecord(post?.record)) {
|
||||||
const text = post.record.text
|
const text = post.record.text
|
||||||
let images
|
|
||||||
let isGif = false
|
|
||||||
|
|
||||||
if (AppBskyEmbedImages.isView(post.embed)) {
|
|
||||||
images = post.embed.images
|
|
||||||
} else if (
|
|
||||||
AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
|
||||||
AppBskyEmbedImages.isView(post.embed.media)
|
|
||||||
) {
|
|
||||||
images = post.embed.media.images
|
|
||||||
} else if (
|
|
||||||
AppBskyEmbedExternal.isView(post.embed) &&
|
|
||||||
post.embed.external.thumb
|
|
||||||
) {
|
|
||||||
let url: URL | undefined
|
|
||||||
try {
|
|
||||||
url = new URL(post.embed.external.uri)
|
|
||||||
} catch {}
|
|
||||||
if (url) {
|
|
||||||
const {success} = parseTenorGif(url)
|
|
||||||
if (success) {
|
|
||||||
isGif = true
|
|
||||||
images = [
|
|
||||||
{
|
|
||||||
thumb: post.embed.external.thumb,
|
|
||||||
alt: post.embed.external.title,
|
|
||||||
fullsize: post.embed.external.thumb,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||||
{images && images.length > 0 && (
|
<MediaPreview.Embed
|
||||||
<ImageHorzList
|
embed={post.embed}
|
||||||
images={images}
|
style={styles.additionalPostImages}
|
||||||
style={styles.additionalPostImages}
|
/>
|
||||||
gif={isGif}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
|
||||||
import {Image} from 'expo-image'
|
|
||||||
import {AppBskyEmbedImages} from '@atproto/api'
|
|
||||||
import {Trans} from '@lingui/macro'
|
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
images: AppBskyEmbedImages.ViewImage[]
|
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
gif?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImageHorzList({images, style, gif}: Props) {
|
|
||||||
return (
|
|
||||||
<View style={[a.flex_row, a.gap_xs, style]}>
|
|
||||||
{images.map(({thumb, alt}) => (
|
|
||||||
<View
|
|
||||||
key={thumb}
|
|
||||||
style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
|
|
||||||
<Image
|
|
||||||
key={thumb}
|
|
||||||
source={{uri: thumb}}
|
|
||||||
style={[a.flex_1, a.rounded_xs]}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
accessibilityHint={alt}
|
|
||||||
accessibilityLabel=""
|
|
||||||
/>
|
|
||||||
{gif && (
|
|
||||||
<View style={styles.altContainer}>
|
|
||||||
<Text style={styles.alt}>
|
|
||||||
<Trans>GIF</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
altContainer: {
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
|
||||||
borderRadius: 6,
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
paddingVertical: 3,
|
|
||||||
position: 'absolute',
|
|
||||||
right: 5,
|
|
||||||
bottom: 5,
|
|
||||||
zIndex: 2,
|
|
||||||
},
|
|
||||||
alt: {
|
|
||||||
color: 'white',
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -244,6 +244,7 @@ let PostCtrls = ({
|
|||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
|
a.overflow_hidden,
|
||||||
{padding: 5},
|
{padding: 5},
|
||||||
(pressed || hovered) && t.atoms.bg_contrast_25,
|
(pressed || hovered) && t.atoms.bg_contrast_25,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
|||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
const Context = React.createContext<{
|
const Context = React.createContext<{
|
||||||
activeSource: string | null
|
activeSource: string
|
||||||
setActiveSource: (src: string) => void
|
activeViewId: string | undefined
|
||||||
|
setActiveSource: (src: string, viewId: string) => void
|
||||||
player: VideoPlayer
|
player: VideoPlayer
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [activeSource, setActiveSource] = React.useState('')
|
const [activeSource, setActiveSource] = React.useState('')
|
||||||
|
const [activeViewId, setActiveViewId] = React.useState<string>()
|
||||||
|
|
||||||
const player = useVideoPlayer(activeSource, p => {
|
const player = useVideoPlayer(activeSource, p => {
|
||||||
p.muted = true
|
p.muted = true
|
||||||
@@ -22,8 +24,19 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
p.play()
|
p.play()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const setActiveSourceOuter = (src: string, viewId: string) => {
|
||||||
|
setActiveSource(src)
|
||||||
|
setActiveViewId(viewId)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={{activeSource, setActiveSource, player}}>
|
<Context.Provider
|
||||||
|
value={{
|
||||||
|
activeSource,
|
||||||
|
setActiveSource: setActiveSourceOuter,
|
||||||
|
activeViewId,
|
||||||
|
player,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|||||||
import {WebView} from 'react-native-webview'
|
import {WebView} from 'react-native-webview'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
@@ -29,6 +28,7 @@ import {useExternalEmbedsPrefs} from '#/state/preferences'
|
|||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
|
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
import {EventStopper} from '../EventStopper'
|
import {EventStopper} from '../EventStopper'
|
||||||
|
|
||||||
interface ShouldStartLoadRequest {
|
interface ShouldStartLoadRequest {
|
||||||
@@ -59,7 +59,7 @@ function PlaceholderOverlay({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[styles.overlayContainer, styles.topRadius]}>
|
style={[styles.overlayContainer, styles.topRadius]}>
|
||||||
{!isPlayerActive ? (
|
{!isPlayerActive ? (
|
||||||
<FontAwesomeIcon icon="play" size={42} color="white" />
|
<PlayButtonIcon />
|
||||||
) : (
|
) : (
|
||||||
<ActivityIndicator size="large" color="white" />
|
<ActivityIndicator size="large" color="white" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -22,6 +21,7 @@ import {atoms as a, useTheme} from '#/alf'
|
|||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
|
||||||
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
|
||||||
|
|
||||||
@@ -69,24 +69,7 @@ function PlaybackControls({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : !isPlaying ? (
|
) : !isPlaying ? (
|
||||||
<View
|
<PlayButtonIcon />
|
||||||
style={[
|
|
||||||
a.rounded_full,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
backgroundColor: t.palette.primary_500,
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="play"
|
|
||||||
size={42}
|
|
||||||
color="white"
|
|
||||||
style={{marginLeft: 8}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
@@ -155,7 +138,6 @@ export function GifEmbed({
|
|||||||
accessibilityHint={_(msg`Animated GIF`)}
|
accessibilityHint={_(msg`Animated GIF`)}
|
||||||
accessibilityLabel={parsedAlt.alt}
|
accessibilityLabel={parsedAlt.alt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />}
|
{!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -183,7 +165,6 @@ function AltText({text}: {text: string}) {
|
|||||||
<Trans>ALT</Trans>
|
<Trans>ALT</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Prompt.Outer control={control}>
|
<Prompt.Outer control={control}>
|
||||||
<Prompt.TitleText>
|
<Prompt.TitleText>
|
||||||
<Trans>Alt Text</Trans>
|
<Trans>Alt Text</Trans>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, {useCallback, 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 {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'
|
||||||
@@ -8,41 +9,42 @@ 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 {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||||
import {ErrorBoundary} from '../ErrorBoundary'
|
import {ErrorBoundary} from '../ErrorBoundary'
|
||||||
import {useActiveVideoNative} from './ActiveVideoNativeContext'
|
import {useActiveVideoNative} from './ActiveVideoNativeContext'
|
||||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||||
|
|
||||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
interface Props {
|
||||||
const t = useTheme()
|
embed: AppBskyEmbedVideo.View
|
||||||
const {activeSource, setActiveSource} = useActiveVideoNative()
|
}
|
||||||
const isActive = embed.playlist === activeSource
|
|
||||||
const {_} = useLingui()
|
export function VideoEmbed({embed}: Props) {
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
const [key, setKey] = useState(0)
|
const [key, setKey] = useState(0)
|
||||||
|
|
||||||
const renderError = useCallback(
|
const renderError = useCallback(
|
||||||
(error: unknown) => (
|
(error: unknown) => (
|
||||||
<VideoError error={error} retry={() => setKey(key + 1)} />
|
<VideoError error={error} retry={() => setKey(key + 1)} />
|
||||||
),
|
),
|
||||||
[key],
|
[key],
|
||||||
)
|
)
|
||||||
const gate = useGate()
|
|
||||||
|
|
||||||
if (!gate('video_view_on_posts')) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let aspectRatio = 16 / 9
|
let aspectRatio = 16 / 9
|
||||||
|
|
||||||
if (embed.aspectRatio) {
|
if (embed.aspectRatio) {
|
||||||
const {width, height} = embed.aspectRatio
|
const {width, height} = embed.aspectRatio
|
||||||
aspectRatio = width / height
|
aspectRatio = width / height
|
||||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!gate('video_view_on_posts')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -54,41 +56,138 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
|||||||
a.my_xs,
|
a.my_xs,
|
||||||
]}>
|
]}>
|
||||||
<ErrorBoundary renderError={renderError} key={key}>
|
<ErrorBoundary renderError={renderError} key={key}>
|
||||||
<VisibilityView
|
<InnerWrapper embed={embed} />
|
||||||
enabled={true}
|
|
||||||
onChangeStatus={isVisible => {
|
|
||||||
if (isVisible) {
|
|
||||||
setActiveSource(embed.playlist)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
{isActive ? (
|
|
||||||
<VideoEmbedInnerNative embed={embed} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Image
|
|
||||||
source={{uri: embed.thumbnail}}
|
|
||||||
alt={embed.alt}
|
|
||||||
style={a.flex_1}
|
|
||||||
contentFit="contain"
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
style={[a.absolute, a.inset_0]}
|
|
||||||
onPress={() => {
|
|
||||||
setActiveSource(embed.playlist)
|
|
||||||
}}
|
|
||||||
label={_(msg`Play video`)}
|
|
||||||
color="secondary">
|
|
||||||
<PlayIcon width={48} fill={t.palette.white} />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</VisibilityView>
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InnerWrapper({embed}: Props) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {activeSource, activeViewId, setActiveSource, player} =
|
||||||
|
useActiveVideoNative()
|
||||||
|
const viewId = useId()
|
||||||
|
|
||||||
|
const [playerStatus, setPlayerStatus] = useState<VideoPlayerStatus>('loading')
|
||||||
|
const [isMuted, setIsMuted] = useState(player.muted)
|
||||||
|
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
||||||
|
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||||
|
const isActive = embed.playlist === activeSource && activeViewId === viewId
|
||||||
|
const isLoading =
|
||||||
|
isActive &&
|
||||||
|
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
|
||||||
|
playerStatus === 'loading')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||||
|
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
||||||
|
setIsMuted(isMuted)
|
||||||
|
})
|
||||||
|
const timeSub = player.addListener(
|
||||||
|
'timeRemainingChange',
|
||||||
|
secondsRemaining => {
|
||||||
|
setTimeRemaining(secondsRemaining)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const statusSub = player.addListener(
|
||||||
|
'statusChange',
|
||||||
|
(status, _oldStatus, error) => {
|
||||||
|
setPlayerStatus(status)
|
||||||
|
if (status === 'error') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
volumeSub.remove()
|
||||||
|
timeSub.remove()
|
||||||
|
statusSub.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [player, isActive])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive && playerStatus !== 'loading') {
|
||||||
|
setPlayerStatus('loading')
|
||||||
|
}
|
||||||
|
}, [isActive, playerStatus])
|
||||||
|
|
||||||
|
const onChangeStatus = (isVisible: boolean) => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
setActiveSource(embed.playlist, viewId)
|
||||||
|
if (!player.playing) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.muted = true
|
||||||
|
if (player.playing) {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
|
||||||
|
{isActive ? (
|
||||||
|
<VideoEmbedInnerNative
|
||||||
|
embed={embed}
|
||||||
|
timeRemaining={timeRemaining}
|
||||||
|
isMuted={isMuted}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
setIsFullscreen={setIsFullscreen}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!isActive || isLoading ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
source={{uri: embed.thumbnail}}
|
||||||
|
alt={embed.alt}
|
||||||
|
style={a.flex_1}
|
||||||
|
contentFit="cover"
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
onPress={() => {
|
||||||
|
setActiveSource(embed.playlist, viewId)
|
||||||
|
}}
|
||||||
|
label={_(msg`Play video`)}
|
||||||
|
color="secondary">
|
||||||
|
{isLoading ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.p_xs,
|
||||||
|
a.absolute,
|
||||||
|
{top: 'auto', left: 'auto'},
|
||||||
|
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
||||||
|
]}>
|
||||||
|
<Loader size="2xl" style={{color: 'white'}} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<PlayButtonIcon />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</VisibilityView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function VideoError({retry}: {error: unknown; retry: () => void}) {
|
function VideoError({retry}: {error: unknown; retry: () => void}) {
|
||||||
return (
|
return (
|
||||||
<VideoFallback.Container>
|
<VideoFallback.Container>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
import React, {useCallback, useRef} from 'react'
|
||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import Animated, {FadeInDown} from 'react-native-reanimated'
|
import Animated, {FadeInDown} from 'react-native-reanimated'
|
||||||
import {VideoPlayer, VideoView} from 'expo-video'
|
import {VideoPlayer, VideoView} from 'expo-video'
|
||||||
@@ -20,13 +20,20 @@ import {TimeIndicator} from './TimeIndicator'
|
|||||||
|
|
||||||
export function VideoEmbedInnerNative({
|
export function VideoEmbedInnerNative({
|
||||||
embed,
|
embed,
|
||||||
|
isFullscreen,
|
||||||
|
setIsFullscreen,
|
||||||
|
isMuted,
|
||||||
|
timeRemaining,
|
||||||
}: {
|
}: {
|
||||||
embed: AppBskyEmbedVideo.View
|
embed: AppBskyEmbedVideo.View
|
||||||
|
isFullscreen: boolean
|
||||||
|
setIsFullscreen: (isFullscreen: boolean) => void
|
||||||
|
timeRemaining: number
|
||||||
|
isMuted: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {player} = useActiveVideoNative()
|
const {player} = useActiveVideoNative()
|
||||||
const ref = useRef<VideoView>(null)
|
const ref = useRef<VideoView>(null)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
||||||
|
|
||||||
const enterFullscreen = useCallback(() => {
|
const enterFullscreen = useCallback(() => {
|
||||||
ref.current?.enterFullscreen()
|
ref.current?.enterFullscreen()
|
||||||
@@ -46,7 +53,7 @@ export function VideoEmbedInnerNative({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
player={player}
|
player={player}
|
||||||
style={[a.flex_1, a.rounded_sm]}
|
style={[a.flex_1, a.rounded_sm]}
|
||||||
contentFit="contain"
|
contentFit="cover"
|
||||||
nativeControls={isFullscreen}
|
nativeControls={isFullscreen}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
onEnterFullscreen={() => {
|
onEnterFullscreen={() => {
|
||||||
@@ -70,7 +77,12 @@ export function VideoEmbedInnerNative({
|
|||||||
}
|
}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
/>
|
/>
|
||||||
<VideoControls player={player} enterFullscreen={enterFullscreen} />
|
<VideoControls
|
||||||
|
player={player}
|
||||||
|
enterFullscreen={enterFullscreen}
|
||||||
|
isMuted={isMuted}
|
||||||
|
timeRemaining={timeRemaining}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,31 +90,16 @@ export function VideoEmbedInnerNative({
|
|||||||
function VideoControls({
|
function VideoControls({
|
||||||
player,
|
player,
|
||||||
enterFullscreen,
|
enterFullscreen,
|
||||||
|
timeRemaining,
|
||||||
|
isMuted,
|
||||||
}: {
|
}: {
|
||||||
player: VideoPlayer
|
player: VideoPlayer
|
||||||
enterFullscreen: () => void
|
enterFullscreen: () => void
|
||||||
|
timeRemaining: number
|
||||||
|
isMuted: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [isMuted, setIsMuted] = useState(player.muted)
|
|
||||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
|
||||||
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
|
||||||
setIsMuted(isMuted)
|
|
||||||
})
|
|
||||||
const timeSub = player.addListener(
|
|
||||||
'timeRemainingChange',
|
|
||||||
secondsRemaining => {
|
|
||||||
setTimeRemaining(secondsRemaining)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return () => {
|
|
||||||
volumeSub.remove()
|
|
||||||
timeSub.remove()
|
|
||||||
}
|
|
||||||
}, [player])
|
|
||||||
|
|
||||||
const onPressFullscreen = useCallback(() => {
|
const onPressFullscreen = useCallback(() => {
|
||||||
switch (player.status) {
|
switch (player.status) {
|
||||||
|
|||||||
Reference in New Issue
Block a user