diff --git a/app.config.js b/app.config.js index cd8a4b034b..25014ee8f1 100644 --- a/app.config.js +++ b/app.config.js @@ -191,7 +191,7 @@ module.exports = function (config) { 'expo-build-properties', { ios: { - deploymentTarget: '14.0', + deploymentTarget: '15.1', newArchEnabled: false, }, android: { diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..118d148f3c --- /dev/null +++ b/assets/icons/crop_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index eaa038292e..3f9f0bced7 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "expo-system-ui": "~3.0.4", "expo-task-manager": "~11.8.1", "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", "fast-text-encoding": "^1.0.6", "history": "^5.3.0", @@ -180,6 +180,7 @@ "react-native-image-crop-picker": "0.40.3", "react-native-ios-context-menu": "^1.15.3", "react-native-keyboard-controller": "^1.12.1", + "react-native-mmkv": "^2.12.2", "react-native-pager-view": "6.2.3", "react-native-picker-select": "^9.1.3", "react-native-progress": "bluesky-social/react-native-progress", diff --git a/patches/expo-video+1.2.4.patch b/patches/expo-video+1.2.4.patch deleted file mode 100644 index bc20fa0ea4..0000000000 --- a/patches/expo-video+1.2.4.patch +++ /dev/null @@ -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) { - 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(androidx.media3.ui.R.id.exo_fullscreen) -+ val fullscreenButton = -+ findViewById(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>() - -+ private var previouslyPlayingViews: MutableList? = 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() - 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() - val onPictureInPictureStop by EventDispatcher() -+ 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.weakObjects() - private var videoPlayers = NSHashTable.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, Hashable, VideoPlayerObse - safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource) - } - -+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) { -+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining) -+ } -+ - func safeEmit(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) { - if playerItem.isPlaybackBufferEmpty { -- status = .loading -+ status = .playbackBufferEmpty - } - } - - private func onPlayerLikelyToKeepUpChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange) { - 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; - } diff --git a/patches/expo-video+1.2.4.patch.md b/patches/expo-video+1.2.4.patch.md deleted file mode 100644 index 7cd4d363a5..0000000000 --- a/patches/expo-video+1.2.4.patch.md +++ /dev/null @@ -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. diff --git a/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js index 61d5f81b07..c14d27291a 100644 --- a/plugins/starterPackAppClipExtension/withXcodeTarget.js +++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js @@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => { buildSettingsObj.SWIFT_VERSION = '5.0' buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"` buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS' - buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0' + buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1' buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon' } } diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 315ad0dfda..86cb5c315a 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' 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<{ titleId: string @@ -23,7 +26,7 @@ export function Outer({ control, testID, }: React.PropsWithChildren<{ - control: Dialog.DialogOuterProps['control'] + control: Dialog.DialogControlProps testID?: string }>) { const {gtMobile} = useBreakpoints() diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index aefd62b9ac..3db00aece6 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -2,7 +2,7 @@ import React from 'react' import {View} from 'react-native' 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' let MessageItemEmbed = ({ @@ -14,7 +14,11 @@ let MessageItemEmbed = ({ return ( - + ) } diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx new file mode 100644 index 0000000000..4b3fc560f9 --- /dev/null +++ b/src/components/icons/Crop.tsx @@ -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', +}) diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 332b9309aa..2a6cfae913 100644 --- a/src/locale/i18n.ts +++ b/src/locale/i18n.ts @@ -37,82 +37,130 @@ export async function dynamicActivate(locale: AppLanguage) { switch (locale) { case AppLanguage.ca: { 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 } case AppLanguage.de: { 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 } case AppLanguage.es: { 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 } case AppLanguage.fi: { 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 } case AppLanguage.fr: { 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 } case AppLanguage.ga: { 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 } case AppLanguage.hi: { 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 } case AppLanguage.id: { 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 } case AppLanguage.it: { 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 } case AppLanguage.ja: { 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 } case AppLanguage.ko: { 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 } case AppLanguage.pt_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 } case AppLanguage.tr: { 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 } case AppLanguage.uk: { 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 } case AppLanguage.zh_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 } case AppLanguage.zh_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 } default: { diff --git a/src/locale/locales/pt-BR/messages.po b/src/locale/locales/pt-BR/messages.po index 98560bf97b..3a67499b4c 100644 --- a/src/locale/locales/pt-BR/messages.po +++ b/src/locale/locales/pt-BR/messages.po @@ -5576,7 +5576,7 @@ msgstr "Repostado por <0><1/>" #: src/view/com/posts/FeedItem.tsx:292 #: src/view/com/posts/FeedItem.tsx:311 msgid "Reposted by you" -msgstr "repostou para vocĂȘ" +msgstr "Repostado por vocĂȘ" #: src/view/com/notifications/FeedItem.tsx:184 msgid "reposted your post" diff --git a/src/storage/README.md b/src/storage/README.md new file mode 100644 index 0000000000..b7d8d35610 --- /dev/null +++ b/src/storage/README.md @@ -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). diff --git a/src/storage/__tests__/index.test.ts b/src/storage/__tests__/index.test.ts new file mode 100644 index 0000000000..e11affa7a8 --- /dev/null +++ b/src/storage/__tests__/index.test.ts @@ -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 +} + +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) +}) diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000000..819ffab7ec --- /dev/null +++ b/src/storage/index.ts @@ -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 { + 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( + scopes: [...Scopes, Key], + data: Schema[Key], + ): void { + // stored as `{ data: }` 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( + scopes: [...Scopes, Key], + ): Schema[Key] | undefined { + const res = this.store.getString(scopes.join(this.sep)) + if (!res) return undefined + // parsed from storage structure `{ data: }` + return JSON.parse(res).data + } + + /** + * Remove a value from storage based on scopes and/or keys + * + * `remove([key])` + * `remove([scope, key])` + */ + remove(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(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'}) diff --git a/src/storage/schema.ts b/src/storage/schema.ts new file mode 100644 index 0000000000..6522d75a36 --- /dev/null +++ b/src/storage/schema.ts @@ -0,0 +1,4 @@ +/** + * Device data that's specific to the device and does not vary based account + */ +export type Device = {} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 6a6ac72674..3ca709a1e0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -24,11 +24,14 @@ import Animated, { FadeIn, FadeOut, interpolateColor, + LayoutAnimationConfig, useAnimatedStyle, useDerivedValue, useSharedValue, withRepeat, withTiming, + ZoomIn, + ZoomOut, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import { @@ -84,7 +87,7 @@ import {GalleryModel} from 'state/models/media/gallery' import {State as VideoUploadState} from 'state/queries/video/video' import {ComposerOpts} from 'state/shell/composer' 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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' @@ -766,29 +769,36 @@ export const ComposePost = observer(function ComposePost({ )} ) : null} - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( - - ) : videoUploadState.video ? ( - - ) : null)} - {(videoUploadState.asset || videoUploadState.video) && ( - - )} + + {(videoUploadState.asset || videoUploadState.video) && ( + + {videoUploadState.asset && + (videoUploadState.status === 'compressing' ? ( + + ) : videoUploadState.video ? ( + + ) : null)} + + + )} + diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index d8accd062b..6e294ba9c3 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -1,4 +1,5 @@ import React, {useCallback} from 'react' +import {Keyboard} from 'react-native' import { ImagePickerAsset, launchImageLibraryAsync, @@ -10,11 +11,14 @@ import {useLingui} from '@lingui/react' import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' import {isNative} from '#/platform/detection' +import {useModalControls} from '#/state/modals' +import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' 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 = { onSelectVideo: (video: ImagePickerAsset) => void @@ -26,33 +30,47 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { const {_} = useLingui() const t = useTheme() const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() + const control = Prompt.usePromptControl() + const {currentAccount} = useSession() const onPressSelectVideo = useCallback(async () => { if (isNative && !(await requestVideoAccessIfNeeded())) { return } - const response = await launchImageLibraryAsync({ - exif: false, - mediaTypes: MediaTypeOptions.Videos, - videoMaxDuration: VIDEO_MAX_DURATION, - quality: 1, - legacy: true, - preferredAssetRepresentationMode: - UIImagePickerPreferredAssetRepresentationMode.Current, - }) - if (response.assets && response.assets.length > 0) { - try { - onSelectVideo(response.assets[0]) - } catch (err) { - if (err instanceof Error) { - setError(err.message) - } else { - setError(_(msg`An error occurred while selecting the video`)) + if (!currentAccount?.emailConfirmed) { + Keyboard.dismiss() + control.open() + } else { + const response = await launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Videos, + videoMaxDuration: VIDEO_MAX_DURATION, + quality: 1, + legacy: true, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Current, + }) + if (response.assets && response.assets.length > 0) { + try { + onSelectVideo(response.assets[0]) + } 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 ( <> @@ -71,6 +89,32 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { style={disabled && t.atoms.text_contrast_low} /> + ) } + +function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { + const {_} = useLingui() + const {openModal} = useModalControls() + + return ( + { + control.close(() => { + openModal({ + name: 'verify-email', + showReminder: false, + }) + }) + }} + /> + ) +} diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx index 9cd8eae470..a1cdb906d2 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -34,7 +34,7 @@ export function SubtitleDialogBtn(props: Props) { const {_} = useLingui() return ( - + - - ) : null} + + {embed.alt} + + ) } diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 0001a7af5a..a25f946416 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -1,13 +1,15 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' import {View} from 'react-native' 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 {useGate} from '#/lib/statsig/statsig' import { HLSUnsupportedError, VideoEmbedInnerWeb, + VideoNotFoundError, } from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' import {atoms as a} from '#/alf' import {ErrorBoundary} from '../ErrorBoundary' @@ -152,23 +154,26 @@ function ViewportObserver({ } 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 ( - - {isHLS ? ( - - Your browser does not support the video format. Please try a - different browser. - - ) : ( - - An error occurred while loading the video. Please try again later. - - )} - - {!isHLS && } + {text} + {showRetryButton && } ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 3fa159267d..b747223baa 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -56,13 +56,13 @@ export function VideoEmbedInnerNative({ contentFit="cover" nativeControls={isFullscreen} accessibilityIgnoresInvertColors - onEnterFullscreen={() => { + onFullscreenEnter={() => { PlatformInfo.setAudioCategory(AudioCategory.Playback) PlatformInfo.setAudioActive(true) player.muted = false setIsFullscreen(true) }} - onExitFullscreen={() => { + onFullscreenExit={() => { PlatformInfo.setAudioCategory(AudioCategory.Ambient) PlatformInfo.setAudioActive(false) player.muted = true diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 77295c00c7..a30c0e1e9b 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -23,6 +23,12 @@ export function VideoEmbedInnerWeb({ const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) const figId = useId() + // send error up to error boundary + const [error, setError] = useState(null) + if (error) { + throw error + } + const hlsRef = useRef(undefined) useEffect(() => { @@ -38,12 +44,25 @@ export function VideoEmbedInnerWeb({ // initial value, later on it's managed by Controls 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) { 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 () => { hlsRef.current = undefined hls.detachMedia() @@ -104,3 +123,9 @@ export class HLSUnsupportedError extends Error { super('HLS is not supported') } } + +export class VideoNotFoundError extends Error { + constructor() { + super('Video not found') + } +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx index d9b99ef3ad..82c0ab7a66 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx @@ -111,9 +111,9 @@ export function Controls({ // autoplay/pause based on visibility const autoplayDisabled = useAutoplayDisabled() useEffect(() => { - if (active && !autoplayDisabled) { + if (active) { if (onScreen) { - play() + if (!autoplayDisabled) play() } else { pause() } @@ -151,10 +151,11 @@ export function Controls({ const onPressEmptySpace = useCallback(() => { if (!focused) { drawFocus() + if (autoplayDisabled) play() } else { togglePlayPause() } - }, [togglePlayPause, drawFocus, focused]) + }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) const onPressPlayPause = useCallback(() => { drawFocus() @@ -240,7 +241,8 @@ export function Controls({ }, []) const showControls = - (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) + ((focused || autoplayDisabled) && !playing) || + (interactingViaKeypress ? hasFocus : hovered) return (
{!showControls && !focused && duration > 0 && ( diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index d9e075e772..b4a6cf8251 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -3,7 +3,6 @@ import { InteractionManager, StyleProp, StyleSheet, - Text, View, ViewStyle, } from 'react-native' @@ -22,7 +21,6 @@ import { } from '@atproto/api' import {ImagesLightbox, useLightboxControls} from '#/state/lightbox' -import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePalette} from 'lib/hooks/usePalette' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' @@ -34,8 +32,11 @@ import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' +import {PostEmbedViewContext, QuoteEmbedViewContext} from './types' import {VideoEmbed} from './VideoEmbed' +export * from './types' + type Embed = | AppBskyEmbedRecord.View | AppBskyEmbedImages.View @@ -50,15 +51,16 @@ export function PostEmbeds({ onOpen, style, allowNestedQuotes, + viewContext, }: { embed?: Embed moderation?: ModerationDecision onOpen?: () => void style?: StyleProp allowNestedQuotes?: boolean + viewContext?: PostEmbedViewContext }) { const {openLightbox} = useLightboxControls() - const largeAltBadge = useLargeAltBadgeEnabled() // quote post with media // = @@ -69,8 +71,17 @@ export function PostEmbeds({ embed={embed.media} moderation={moderation} onOpen={onOpen} + viewContext={viewContext} + /> + - ) } @@ -124,27 +135,26 @@ export function PostEmbeds({ } if (images.length === 1) { - const {alt, thumb, aspectRatio} = images[0] + const image = images[0] return ( _openLightbox(0)} onPressIn={() => onPressIn(0)} - style={a.rounded_sm}> - {alt === '' ? null : ( - - - ALT - - - )} - + hideBadge={ + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> ) @@ -157,6 +167,7 @@ export function PostEmbeds({ images={embed.images} onPress={_openLightbox} onPressIn={onPressIn} + viewContext={viewContext} /> diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts new file mode 100644 index 0000000000..08e9032768 --- /dev/null +++ b/src/view/com/util/post-embeds/types.ts @@ -0,0 +1,9 @@ +export enum PostEmbedViewContext { + ThreadHighlighted = 'ThreadHighlighted', + Feed = 'Feed', + FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia', +} + +export enum QuoteEmbedViewContext { + FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia, +} diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx index 5cb5c6a39f..2992e5c7e9 100644 --- a/src/view/screens/AccessibilitySettings.tsx +++ b/src/view/screens/AccessibilitySettings.tsx @@ -108,7 +108,7 @@ export function AccessibilitySettingsScreen({}: Props) { setAutoplayDisabled(!autoplayDisabled)} diff --git a/yarn.lock b/yarn.lock index b084ca26fe..b241ab57ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9917,12 +9917,7 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520: - 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: +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== @@ -12419,10 +12414,9 @@ expo-updates@~0.25.14: ignore "^5.3.1" 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" - resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.2.4.tgz#787342aded4295a1b6864f59227d178b93e1bb53" - integrity sha512-pBK9mt7vYAbuPQjCSQxHQ7xrNjbmRheJep7JIStEg57O183/JRfP2blKuXniiSt1HBdZYPdoQnGRa3jGMXB9pg== + 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" expo-web-browser@~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" 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: version "6.2.3" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"