diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7ec0d90d6..740a64ca7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -51,6 +51,7 @@ "@react-native-masked-view/masked-view": "0.3.2", "@react-native-menu/menu": "2.0.0", "@react-native-picker/picker": "2.11.4", + "@rntp/player": "5.9.2", "@shikijs/themes": "4.3.1", "@shopify/flash-list": "2.3.2", "@tanstack/query-sync-storage-persister": "5.101.2", @@ -134,7 +135,6 @@ "react-native-screens": "4.25.2", "react-native-sheet-transitions": "0.1.2", "react-native-svg": "15.15.5", - "react-native-track-player": "4.1.2", "react-native-uikit-colors": "0.6.2", "react-native-volume-manager": "2.0.8", "react-native-web": "0.21.2", @@ -190,7 +190,7 @@ "reactNativeDirectoryCheck": { "enabled": true, "exclude": [ - "react-native-track-player", + "@rntp/player", "follow-native", "react-native-color-matrix-image-filters" ], diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index c298d310b..af731657b 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -1,11 +1,11 @@ import { EventBus } from "@follow/utils/event-bus" import { Portal } from "@gorhom/portal" +import TrackPlayer from "@rntp/player" import { useAtom } from "jotai" import * as React from "react" import { useCallback, useEffect } from "react" import { Dimensions, StyleSheet, View } from "react-native" import { runOnJS, runOnUI } from "react-native-reanimated" -import TrackPlayer from "react-native-track-player" import { player } from "@/src/lib/player" @@ -38,7 +38,7 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { const handleSeekAudio = useCallback( async (e: AudioSeekEvent) => { - const activeTrack = await TrackPlayer.getActiveTrack() + const activeTrack = TrackPlayer.getActiveMediaItem() const entryAudio = entryInWebview?.attachments?.find((attachment) => attachment.mime_type?.startsWith("audio/"), ) @@ -46,7 +46,7 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { console.warn("Failed to seek audio! No audio attachment found") return } - if (activeTrack?.url !== entryAudio.url) { + if (activeTrack?.mediaId !== entryAudio.url) { await player.play({ url: entryAudio.url || "", title: entryInWebview?.title || "Unknown Title", diff --git a/apps/mobile/src/initialize/player.test.ts b/apps/mobile/src/initialize/player.test.ts new file mode 100644 index 000000000..5f67a3964 --- /dev/null +++ b/apps/mobile/src/initialize/player.test.ts @@ -0,0 +1,131 @@ +import type { AppStateStatus } from "react-native" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => { + const listeners = new Set<(state: AppStateStatus) => void>() + return { + listeners, + appState: { + currentState: "background" as AppStateStatus, + addEventListener: vi.fn((_event: string, listener: (state: AppStateStatus) => void) => { + listeners.add(listener) + return { remove: () => listeners.delete(listener) } + }), + }, + platform: { OS: "android" }, + setupPlayer: vi.fn(), + setCommands: vi.fn(), + } +}) + +vi.mock("react-native", () => ({ + AppState: mocks.appState, + Platform: mocks.platform, +})) + +vi.mock("@rntp/player", () => ({ + default: { + setupPlayer: mocks.setupPlayer, + setCommands: mocks.setCommands, + }, + PlayerCommand: { + PlayPause: "playPause", + Next: "next", + Previous: "previous", + Stop: "stop", + Seek: "seek", + }, +})) + +function changeAppState(state: AppStateStatus) { + mocks.appState.currentState = state + for (const listener of mocks.listeners) { + listener(state) + } +} + +describe("initializePlayer", () => { + beforeEach(() => { + vi.resetModules() + mocks.listeners.clear() + mocks.appState.currentState = "background" + mocks.platform.OS = "android" + mocks.setupPlayer.mockReset() + mocks.setCommands.mockReset() + mocks.appState.addEventListener.mockClear() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("waits for the Android foreground before enabling native remote controls", async () => { + const player = await import("./player") + const initialization = player.initializePlayer() + + expect(mocks.setupPlayer).not.toHaveBeenCalled() + expect(player.PlayerRegistered).toBe(false) + + changeAppState("inactive") + await Promise.resolve() + expect(mocks.setupPlayer).not.toHaveBeenCalled() + + changeAppState("active") + await initialization + + expect(mocks.setupPlayer).toHaveBeenCalledOnce() + expect(mocks.setCommands).toHaveBeenCalledWith({ + capabilities: ["playPause", "next", "previous", "stop", "seek"], + handling: "native", + }) + expect(player.PlayerRegistered).toBe(true) + expect(mocks.listeners.size).toBe(0) + }) + + it("initializes once when callers are waiting for the same foreground transition", async () => { + const player = await import("./player") + const first = player.initializePlayer() + const second = player.initializePlayer() + + changeAppState("active") + await Promise.all([first, second]) + changeAppState("background") + await player.initializePlayer() + + expect(mocks.setupPlayer).toHaveBeenCalledOnce() + expect(mocks.setCommands).toHaveBeenCalledOnce() + expect(mocks.listeners.size).toBe(0) + }) + + it("does not require a foreground transition on iOS", async () => { + mocks.platform.OS = "ios" + const player = await import("./player") + + await player.initializePlayer() + + expect(mocks.setupPlayer).toHaveBeenCalledOnce() + expect(mocks.appState.addEventListener).not.toHaveBeenCalled() + expect(player.PlayerRegistered).toBe(true) + }) + + it("keeps a failed setup unregistered so a later call can retry", async () => { + mocks.appState.currentState = "active" + const error = new Error("Native setup failed") + mocks.setupPlayer.mockImplementationOnce(() => { + throw error + }) + const logError = vi.spyOn(console, "error").mockImplementation(() => {}) + const player = await import("./player") + + await player.initializePlayer() + + expect(player.PlayerRegistered).toBe(false) + expect(mocks.setCommands).not.toHaveBeenCalled() + expect(logError).toHaveBeenCalledWith("Failed to setup player:", error) + + await player.initializePlayer() + + expect(mocks.setupPlayer).toHaveBeenCalledTimes(2) + expect(player.PlayerRegistered).toBe(true) + }) +}) diff --git a/apps/mobile/src/initialize/player.ts b/apps/mobile/src/initialize/player.ts index a658542d8..5dec44c5a 100644 --- a/apps/mobile/src/initialize/player.ts +++ b/apps/mobile/src/initialize/player.ts @@ -1,5 +1,5 @@ +import TrackPlayer, { PlayerCommand } from "@rntp/player" import { AppState, Platform } from "react-native" -import TrackPlayer, { Capability, Event } from "react-native-track-player" export let PlayerRegistered = false @@ -16,14 +16,7 @@ function waitForForeground(): Promise { } export async function initializePlayer() { - TrackPlayer.registerPlaybackService(() => async () => { - TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play()) - TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause()) - TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop()) - TrackPlayer.addEventListener(Event.RemoteNext, () => TrackPlayer.skipToNext()) - TrackPlayer.addEventListener(Event.RemotePrevious, () => TrackPlayer.skipToPrevious()) - TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => TrackPlayer.seekTo(position)) - }) + if (PlayerRegistered) return // On Android, setupPlayer must be called in the foreground to avoid // ForegroundServiceStartNotAllowedException @@ -31,38 +24,22 @@ export async function initializePlayer() { await waitForForeground() } - const setup = async (retry = 10) => { - if (retry <= 0) { - console.error("Failed to setup player after multiple attempts") - return - } - try { - await TrackPlayer.setupPlayer() - PlayerRegistered = true - } catch (_err) { - const err = _err as Error & { code?: string } - console.error("Failed to setup player:", "Code:", err.code, err.message) + if (PlayerRegistered) return - if (err.code === "android_cannot_setup_player_in_background") { - await waitForForeground() - await setup(retry - 1) - } - } + try { + TrackPlayer.setupPlayer() + TrackPlayer.setCommands({ + capabilities: [ + PlayerCommand.PlayPause, + PlayerCommand.Next, + PlayerCommand.Previous, + PlayerCommand.Stop, + PlayerCommand.Seek, + ], + handling: "native", + }) + PlayerRegistered = true + } catch (error) { + console.error("Failed to setup player:", error) } - - await setup() - - if (!PlayerRegistered) return - - await TrackPlayer.updateOptions({ - capabilities: [ - Capability.Play, - Capability.Pause, - Capability.SkipToNext, - Capability.SkipToPrevious, - Capability.Stop, - Capability.SeekTo, - ], - compactCapabilities: [Capability.Play, Capability.Pause], - }) } diff --git a/apps/mobile/src/lib/player.test.ts b/apps/mobile/src/lib/player.test.ts new file mode 100644 index 000000000..ebd779293 --- /dev/null +++ b/apps/mobile/src/lib/player.test.ts @@ -0,0 +1,210 @@ +import type { MediaItem } from "@rntp/player" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => { + const state = { + registered: true, + activeItem: null as MediaItem | null, + nativePlaying: false, + playbackState: "idle", + } + const sequence: string[] = [] + return { + state, + sequence, + native: { + getActiveMediaItem: vi.fn(() => state.activeItem), + getPlaybackState: vi.fn(() => state.playbackState), + isPlaying: vi.fn(() => state.nativePlaying), + setMediaItem: vi.fn((item: MediaItem) => { + state.activeItem = item + sequence.push("load") + }), + play: vi.fn(() => { + sequence.push("play") + }), + pause: vi.fn(() => { + state.nativePlaying = false + }), + clear: vi.fn(() => { + state.activeItem = null + state.nativePlaying = false + }), + setupPlayer: vi.fn(), + destroy: vi.fn(), + }, + toastError: vi.fn(), + } +}) + +vi.mock("@rntp/player", () => ({ + default: mocks.native, + PlaybackState: { + Idle: "idle", + Ready: "ready", + Buffering: "buffering", + Ended: "ended", + Error: "error", + }, + useActiveMediaItem: vi.fn(), + useIsPlaying: vi.fn(), + usePlaybackState: vi.fn(), + useProgress: vi.fn(), +})) + +vi.mock("@follow/utils", async () => { + const { createStore } = await import("jotai/vanilla") + return { jotaiStore: createStore() } +}) + +vi.mock("../initialize/player", () => ({ + get PlayerRegistered() { + return mocks.state.registered + }, +})) + +vi.mock("../modules/player/tts-stream-controller", () => ({ + ttsStreamController: {}, +})) + +vi.mock("./toast", () => ({ + toast: { error: mocks.toastError }, +})) + +describe("player", () => { + let player: (typeof import("./player"))["player"] + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + mocks.state.registered = true + mocks.state.activeItem = null + mocks.state.nativePlaying = false + mocks.state.playbackState = "idle" + mocks.sequence.length = 0 + const playerModule = await import("./player") + player = playerModule.player + }) + + it("loads a new media item with V5 artwork metadata before starting playback", async () => { + mocks.state.activeItem = { url: "https://example.com/previous.mp3" } + + await player.play({ + url: "https://example.com/podcast.mp3", + title: "Podcast Episode", + artist: "Podcast Author", + artwork: "https://example.com/cover.jpg", + }) + + expect(mocks.native.setMediaItem).toHaveBeenCalledWith({ + mediaId: "https://example.com/podcast.mp3", + url: "https://example.com/podcast.mp3", + title: "Podcast Episode", + artist: "Podcast Author", + artworkUrl: "https://example.com/cover.jpg", + }) + expect(mocks.sequence).toEqual(["load", "play"]) + }) + + it("supplies display defaults when media metadata is missing", async () => { + await player.play({ + url: "https://example.com/audio.mp3", + title: null, + artist: null, + artwork: null, + }) + + expect(mocks.native.setMediaItem).toHaveBeenCalledWith({ + mediaId: "https://example.com/audio.mp3", + url: "https://example.com/audio.mp3", + title: "Unknown Title", + artist: "Unknown Artist", + artworkUrl: undefined, + }) + }) + + it("resumes the active URL without replacing its queue item or resetting progress", async () => { + mocks.state.activeItem = { + mediaId: "https://example.com/audio.mp3", + url: "https://example.com/audio.mp3", + } + + await player.play({ url: "https://example.com/audio.mp3" }) + + expect(mocks.native.setMediaItem).not.toHaveBeenCalled() + expect(mocks.sequence).toEqual(["play"]) + }) + + it("resumes a local file when iOS returns its URL as a decoded filesystem path", async () => { + const url = "file:///var/mobile/Library/Caches/tts/entry%201.mp3" + await player.play({ url }) + + expect(mocks.native.setMediaItem).toHaveBeenCalledWith( + expect.objectContaining({ mediaId: url }), + ) + + mocks.state.activeItem = { + mediaId: url, + url: "/var/mobile/Library/Caches/tts/entry 1.mp3", + } + mocks.native.setMediaItem.mockClear() + mocks.sequence.length = 0 + + await player.play({ url }) + + expect(mocks.native.setMediaItem).not.toHaveBeenCalled() + expect(mocks.sequence).toEqual(["play"]) + }) + + it("pauses and clears media without destroying the initialized player", async () => { + const track = { url: "https://example.com/audio.mp3" } + await player.play(track) + + await player.pause() + + expect(mocks.native.pause).toHaveBeenCalledOnce() + expect(mocks.state.activeItem?.url).toBe(track.url) + + await player.reset() + + expect(mocks.native.clear).toHaveBeenCalledOnce() + expect(mocks.state.activeItem).toBeNull() + expect(mocks.native.destroy).not.toHaveBeenCalled() + expect(mocks.native.setupPlayer).not.toHaveBeenCalled() + + await player.play(track) + + expect(mocks.native.setMediaItem).toHaveBeenCalledTimes(2) + expect(mocks.native.play).toHaveBeenCalledTimes(2) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it("keeps buffering pausable and distinguishes explicit pause from pending playback", async () => { + mocks.state.playbackState = "buffering" + expect(player.isPlaying()).toBe(true) + + await player.pause() + expect(player.isPlaying()).toBe(false) + + await player.play() + expect(player.isPlaying()).toBe(true) + + mocks.state.playbackState = "ready" + mocks.state.nativePlaying = true + expect(player.isPlaying()).toBe(true) + + await player.pause() + expect(player.isPlaying()).toBe(false) + }) + + it("shows an initialization error without calling native APIs before registration", async () => { + mocks.state.registered = false + + await player.play({ url: "https://example.com/audio.mp3" }) + + expect(mocks.toastError).toHaveBeenCalledOnce() + for (const nativeMethod of Object.values(mocks.native)) { + expect(nativeMethod).not.toHaveBeenCalled() + } + }) +}) diff --git a/apps/mobile/src/lib/player.ts b/apps/mobile/src/lib/player.ts index 6d19a4931..f2055b0ff 100644 --- a/apps/mobile/src/lib/player.ts +++ b/apps/mobile/src/lib/player.ts @@ -1,6 +1,12 @@ +import { jotaiStore } from "@follow/utils" +import TrackPlayer, { + PlaybackState, + useActiveMediaItem, + useIsPlaying as useNativeIsPlaying, + usePlaybackState, +} from "@rntp/player" import { atom, useAtom } from "jotai" import { useCallback, useEffect, useSyncExternalStore } from "react" -import TrackPlayer, { useActiveTrack, useIsPlaying } from "react-native-track-player" import { PlayerRegistered } from "../initialize/player" import { ttsStreamController } from "../modules/player/tts-stream-controller" @@ -8,27 +14,52 @@ import { toast } from "./toast" export type SimpleMediaState = "playing" | "paused" | "loading" -/** - * Learn more https://rntp.dev/docs/guides/play-button - */ -export function useAudioPlayState(audioUrl?: string): SimpleMediaState { - const playState = useIsPlaying() - const activeTrack = useActiveTrack() - const playingUrl = activeTrack?.url +const pausedByUserAtom = atom(false) - const isCurrentTrack = !audioUrl || playingUrl === audioUrl - if (!playingUrl || !isCurrentTrack) { +function usePlaybackStatus() { + const playing = useNativeIsPlaying() + const playbackState = usePlaybackState() + const [pausedByUser, setPausedByUser] = useAtom(pausedByUserAtom) + + useEffect(() => { + if (playing) setPausedByUser(false) + }, [playing, setPausedByUser]) + + // V5 reports actual audio output, so buffering must remain separately pausable. + const buffering = playbackState === PlaybackState.Buffering && !pausedByUser + return { playing: playing || buffering, buffering } +} + +export function useIsPlaying() { + return usePlaybackStatus().playing +} + +export function useAudioPlayState(audioUrl?: string): SimpleMediaState { + const { playing, buffering } = usePlaybackStatus() + const activeTrack = useActiveMediaItem() + const playingMediaId = activeTrack?.mediaId + + const isCurrentTrack = !audioUrl || playingMediaId === audioUrl + if (!playingMediaId || !isCurrentTrack) { // By default the audio should be in "paused" state return "paused" } - if (playState.bufferingDuringPlay === true) { + if (buffering) { return "loading" } - return playState.playing ? "playing" : "paused" + return playing ? "playing" : "paused" } class Player { + isPlaying() { + return ( + TrackPlayer.isPlaying() || + (TrackPlayer.getPlaybackState() === PlaybackState.Buffering && + !jotaiStore.get(pausedByUserAtom)) + ) + } + async play(newTrack?: { url: string title?: string | null @@ -37,44 +68,50 @@ class Player { }) { if (!PlayerRegistered) { toast.error("Player is not registered. Please wait for the app to initialize.") + return } if (newTrack) { - const activeTrack = await TrackPlayer.getActiveTrack() - if (activeTrack?.url !== newTrack.url) { + const activeTrack = TrackPlayer.getActiveMediaItem() + if (activeTrack?.mediaId !== newTrack.url) { const { url, title, artist, artwork } = newTrack - await TrackPlayer.load({ + // Keep identity stable when native playback normalizes local file URLs. + TrackPlayer.setMediaItem({ + mediaId: url, url, title: title ?? "Unknown Title", artist: artist ?? "Unknown Artist", - artwork: artwork ?? undefined, + artworkUrl: artwork ?? undefined, }) } } - await TrackPlayer.play() + TrackPlayer.play() + jotaiStore.set(pausedByUserAtom, false) } async pause() { - await TrackPlayer.pause() + TrackPlayer.pause() + jotaiStore.set(pausedByUserAtom, true) } async reset() { - await TrackPlayer.reset() + TrackPlayer.clear() + jotaiStore.set(pausedByUserAtom, true) } async seekBy(offset: number) { - await TrackPlayer.seekBy(offset) + TrackPlayer.seekBy(offset) } async seekTo(position: number) { - await TrackPlayer.seekTo(position) + TrackPlayer.seekTo(position) } } export const player = new Player() -export { useActiveTrack, useIsPlaying, useProgress } from "react-native-track-player" +export { useProgress } from "@rntp/player" export interface ActivePlayable { artwork?: string | null @@ -89,7 +126,7 @@ export function useTtsStreamPlayback() { } export function useActivePlayable(): ActivePlayable | null { - const activeTrack = useActiveTrack() + const activeTrack = useActiveMediaItem() const ttsStream = useTtsStreamPlayback() if (ttsStream.entryId) { @@ -107,7 +144,7 @@ export function useActivePlayable(): ActivePlayable | null { } return { - artwork: activeTrack.artwork, + artwork: typeof activeTrack.artworkUrl === "string" ? activeTrack.artworkUrl : undefined, artist: activeTrack.artist, entryId: null, kind: "track-player", @@ -124,8 +161,8 @@ export function useRate() { const [rate, setRate] = useAtom(rateAtom) useEffect(() => { - async function getRate() { - const rate = await TrackPlayer.getRate() + function getRate() { + const rate = TrackPlayer.getPlaybackSpeed() if (allowedRate.includes(rate)) { setRate(rate as Rate) } else { @@ -137,9 +174,9 @@ export function useRate() { }, [setRate]) const setRateAndSave = useCallback( - async (rate: Rate) => { + (rate: Rate) => { if (allowedRate.includes(rate)) { - await TrackPlayer.setRate(rate) + TrackPlayer.setPlaybackSpeed(rate) setRate(rate) } }, diff --git a/apps/mobile/src/modules/player/control.tsx b/apps/mobile/src/modules/player/control.tsx index 00aae6d99..31d11fda3 100644 --- a/apps/mobile/src/modules/player/control.tsx +++ b/apps/mobile/src/modules/player/control.tsx @@ -37,7 +37,7 @@ type ControlButtonProps = { } export function PlayPauseButton({ size = 24, className, color }: ControlButtonProps) { const ttsStream = useTtsStreamPlayback() - const { playing } = useIsPlaying() + const playing = useIsPlaying() const isStreamPlaying = ttsStream.status === "playing" const isStream = !!ttsStream.entryId const label = useColor("label") @@ -194,7 +194,7 @@ const formatSecondsToMinutes = (seconds: number) => { } export function ProgressBar() { const { isBackgroundLight } = usePlayerScreenContext() - const { duration, position } = useProgress(250) + const { duration, position } = useProgress(0.25) const isSliding = useSharedValue(false) const progress = useDerivedValue(() => { return duration > 0 ? position / duration : 0 diff --git a/apps/mobile/src/modules/player/entry-tts.test.ts b/apps/mobile/src/modules/player/entry-tts.test.ts new file mode 100644 index 000000000..6fee933d9 --- /dev/null +++ b/apps/mobile/src/modules/player/entry-tts.test.ts @@ -0,0 +1,95 @@ +import type { MediaItem } from "@rntp/player" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + activeItem: null as MediaItem | null, + nativePlaying: false, + fileUrl: "file:///var/mobile/Library/Caches/tts/entry-1.mp3", + requestTtsFile: vi.fn(), + play: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + streamPlay: vi.fn(async () => { + throw new Error("TTS streaming player is not ready") + }), + toastFetchError: vi.fn(), +})) + +vi.mock("@follow/store/entry/getter", () => ({ + getEntry: () => ({ feedId: "feed-1", title: "Article", media: [] }), +})) + +vi.mock("@follow/store/feed/getter", () => ({ + getFeedById: () => ({ title: "Feed" }), +})) + +vi.mock("@rntp/player", () => ({ + default: { getActiveMediaItem: () => mocks.activeItem }, +})) + +vi.mock("@/src/atoms/settings/general", () => ({ + getGeneralSettings: () => ({ voice: "en-US-AvaMultilingualNeural" }), +})) + +vi.mock("@/src/lib/error-parser", () => ({ + toastFetchError: mocks.toastFetchError, +})) + +vi.mock("@/src/lib/player", () => ({ + player: { + isPlaying: () => mocks.nativePlaying, + play: mocks.play, + pause: mocks.pause, + }, +})) + +vi.mock("./tts-service", () => ({ + getEntryTtsText: () => "Article text", + requestTtsFile: mocks.requestTtsFile, +})) + +vi.mock("./tts-stream-controller", () => ({ + ttsStreamController: { + canToggleEntry: () => false, + play: mocks.streamPlay, + }, +})) + +describe("playEntryTts", () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + mocks.activeItem = null + mocks.nativePlaying = false + mocks.requestTtsFile.mockResolvedValue(mocks.fileUrl) + }) + + it("toggles a cached TTS file using its stable ID after iOS normalizes the URL", async () => { + const { playEntryTts } = await import("./entry-tts") + const options = { toastTitle: "Read article" } + + await playEntryTts("entry-1", options) + + expect(mocks.requestTtsFile).toHaveBeenCalledOnce() + expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ url: mocks.fileUrl })) + + mocks.activeItem = { + mediaId: mocks.fileUrl, + url: "/var/mobile/Library/Caches/tts/entry-1.mp3", + } + mocks.nativePlaying = true + + await playEntryTts("entry-1", options) + + expect(mocks.pause).toHaveBeenCalledOnce() + expect(mocks.requestTtsFile).toHaveBeenCalledOnce() + expect(mocks.streamPlay).toHaveBeenCalledOnce() + + mocks.nativePlaying = false + await playEntryTts("entry-1", options) + + expect(mocks.play).toHaveBeenLastCalledWith() + expect(mocks.play).toHaveBeenCalledTimes(2) + expect(mocks.requestTtsFile).toHaveBeenCalledOnce() + expect(mocks.toastFetchError).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/modules/player/entry-tts.ts b/apps/mobile/src/modules/player/entry-tts.ts index 5c9d30a8e..f9ffd0cec 100644 --- a/apps/mobile/src/modules/player/entry-tts.ts +++ b/apps/mobile/src/modules/player/entry-tts.ts @@ -1,6 +1,6 @@ import { getEntry } from "@follow/store/entry/getter" import { getFeedById } from "@follow/store/feed/getter" -import TrackPlayer, { State } from "react-native-track-player" +import TrackPlayer from "@rntp/player" import { getGeneralSettings } from "@/src/atoms/settings/general" import { toastFetchError } from "@/src/lib/error-parser" @@ -21,8 +21,8 @@ const isSameEntryTtsTrack = async (entryId: string) => { return false } - const activeTrack = await TrackPlayer.getActiveTrack() - return activeTrack?.url === activeTtsTrackUrl + const activeTrack = TrackPlayer.getActiveMediaItem() + return activeTrack?.mediaId === activeTtsTrackUrl } const toggleCurrentTtsPlayback = async () => { @@ -31,14 +31,12 @@ const toggleCurrentTtsPlayback = async () => { return } - const { state } = await TrackPlayer.getPlaybackState() - - if ([State.Playing, State.Buffering, State.Loading].includes(state)) { - await TrackPlayer.pause() + if (player.isPlaying()) { + await player.pause() return } - await TrackPlayer.play() + await player.play() } export const playEntryTts = async ( diff --git a/apps/mobile/src/screens/PlayerScreen.tsx b/apps/mobile/src/screens/PlayerScreen.tsx index 273403cf3..e70f25e3e 100644 --- a/apps/mobile/src/screens/PlayerScreen.tsx +++ b/apps/mobile/src/screens/PlayerScreen.tsx @@ -25,7 +25,7 @@ import { usePrefetchImageColors } from "../store/image/hooks" function CoverArt({ cover }: { cover?: string }) { const scale = useSharedValue(1) const ttsStream = useTtsStreamPlayback() - const { playing } = useIsPlaying() + const playing = useIsPlaying() const isPlaying = ttsStream.entryId ? ttsStream.status === "playing" : playing useEffect(() => { cancelAnimation(scale) diff --git a/patches/react-native-track-player@4.1.2.patch b/patches/react-native-track-player@4.1.2.patch deleted file mode 100644 index 73a7ca325..000000000 --- a/patches/react-native-track-player@4.1.2.patch +++ /dev/null @@ -1,474 +0,0 @@ -diff --git a/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt b/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt -index b2409a09939164c49c0f7a16bb6d3284e8eab8fb..cde697e28b1a098cfc654ebe5c7825f26bf2397e 100644 ---- a/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt -+++ b/android/src/main/java/com/doublesymmetry/trackplayer/module/MusicModule.kt -@@ -251,8 +251,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun updateOptions(data: ReadableMap?, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun updateOptions(data: ReadableMap?, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - val options = Arguments.toBundle(data) - -@@ -264,14 +264,14 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun add(data: ReadableArray?, insertBeforeIndex: Int, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - try { - val tracks = readableArrayToTrackList(data); - if (insertBeforeIndex < -1 || insertBeforeIndex > musicService.tracks.size) { - callback.reject("index_out_of_bounds", "The track index is out of bounds") -- return@launch -+ return@launchInScope - } - val index = if (insertBeforeIndex == -1) musicService.tracks.size else insertBeforeIndex - musicService.add( -@@ -285,11 +285,11 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun load(data: ReadableMap?, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun load(data: ReadableMap?, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - if (data == null) { - callback.resolve(null) -- return@launch -+ return@launchInScope - } - val bundle = Arguments.toBundle(data); - if (bundle is Bundle) { -@@ -301,15 +301,15 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun move(fromIndex: Int, toIndex: Int, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun move(fromIndex: Int, toIndex: Int, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - musicService.move(fromIndex, toIndex) - callback.resolve(null) - } - - @ReactMethod -- fun remove(data: ReadableArray?, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun remove(data: ReadableArray?, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - val inputIndexes = Arguments.toList(data) - if (inputIndexes != null) { - val size = musicService.tracks.size -@@ -321,7 +321,7 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - "index_out_of_bounds", - "One or more indexes was out of bounds" - ) -- return@launch -+ return@launchInScope - } - indexes.add(index) - } -@@ -332,8 +332,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - - @ReactMethod - fun updateMetadataForTrack(index: Int, map: ReadableMap?, callback: Promise) = -- scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - if (index < 0 || index >= musicService.tracks.size) { - callback.reject("index_out_of_bounds", "The index is out of bounds") -@@ -348,8 +348,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun updateNowPlayingMetadata(map: ReadableMap?, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - if (musicService.tracks.isEmpty()) - callback.reject("no_current_item", "There is no current item in the player") -@@ -364,8 +364,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun clearNowPlayingMetadata(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun clearNowPlayingMetadata(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - if (musicService.tracks.isEmpty()) - callback.reject("no_current_item", "There is no current item in the player") -@@ -375,16 +375,16 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun removeUpcomingTracks(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun removeUpcomingTracks(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.removeUpcomingTracks() - callback.resolve(null) - } - - @ReactMethod -- fun skip(index: Int, initialTime: Float, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun skip(index: Int, initialTime: Float, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.skip(index) - -@@ -396,8 +396,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun skipToNext(initialTime: Float, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun skipToNext(initialTime: Float, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.skipToNext() - -@@ -409,8 +409,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun skipToPrevious(initialTime: Float, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun skipToPrevious(initialTime: Float, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.skipToPrevious() - -@@ -422,8 +422,8 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun reset(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun reset(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.stop() - delay(300) // Allow playback to stop -@@ -433,134 +433,134 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun play(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun play(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.play() - callback.resolve(null) - } - - @ReactMethod -- fun pause(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun pause(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.pause() - callback.resolve(null) - } - - @ReactMethod -- fun stop(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun stop(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.stop() - callback.resolve(null) - } - - @ReactMethod -- fun seekTo(seconds: Float, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun seekTo(seconds: Float, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.seekTo(seconds) - callback.resolve(null) - } - - @ReactMethod -- fun seekBy(offset: Float, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun seekBy(offset: Float, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.seekBy(offset) - callback.resolve(null) - } - - @ReactMethod -- fun retry(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun retry(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.retry() - callback.resolve(null) - } - - @ReactMethod -- fun setVolume(volume: Float, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun setVolume(volume: Float, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.setVolume(volume) - callback.resolve(null) - } - - @ReactMethod -- fun getVolume(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getVolume(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(musicService.getVolume()) - } - - @ReactMethod -- fun setRate(rate: Float, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun setRate(rate: Float, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.setRate(rate) - callback.resolve(null) - } - - @ReactMethod -- fun getRate(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getRate(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(musicService.getRate()) - } - - @ReactMethod -- fun setRepeatMode(mode: Int, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun setRepeatMode(mode: Int, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.setRepeatMode(RepeatMode.fromOrdinal(mode)) - callback.resolve(null) - } - - @ReactMethod -- fun getRepeatMode(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getRepeatMode(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(musicService.getRepeatMode().ordinal) - } - - @ReactMethod -- fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun setPlayWhenReady(playWhenReady: Boolean, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - musicService.playWhenReady = playWhenReady - callback.resolve(null) - } - - @ReactMethod -- fun getPlayWhenReady(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getPlayWhenReady(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(musicService.playWhenReady) - } - - @ReactMethod -- fun getTrack(index: Int, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getTrack(index: Int, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - if (index >= 0 && index < musicService.tracks.size) { -- callback.resolve(Arguments.fromBundle(musicService.tracks[index].originalItem)) -+ callback.resolve(musicService.tracks[index].originalItem?.let { Arguments.fromBundle(it) }) - } else { - callback.resolve(null) - } - } - - @ReactMethod -- fun getQueue(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getQueue(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(Arguments.fromList(musicService.tracks.map { it.originalItem })) - } - - @ReactMethod -- fun setQueue(data: ReadableArray?, callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun setQueue(data: ReadableArray?, callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - try { - musicService.clear() -@@ -572,48 +572,48 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun getActiveTrackIndex(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getActiveTrackIndex(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - callback.resolve( - if (musicService.tracks.isEmpty()) null else musicService.getCurrentTrackIndex() - ) - } - - @ReactMethod -- fun getActiveTrack(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getActiveTrack(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - callback.resolve( - if (musicService.tracks.isEmpty()) null -- else Arguments.fromBundle( -- musicService.tracks[musicService.getCurrentTrackIndex()].originalItem -- ) -+ else musicService.tracks[musicService.getCurrentTrackIndex()].originalItem?.let { -+ Arguments.fromBundle(it) -+ } - ) - } - - @ReactMethod -- fun getDuration(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getDuration(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(musicService.getDurationInSeconds()) - } - - @ReactMethod -- fun getBufferedPosition(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getBufferedPosition(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(musicService.getBufferedPositionInSeconds()) - } - - @ReactMethod -- fun getPosition(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getPosition(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - - callback.resolve(musicService.getPositionInSeconds()) - } - - @ReactMethod -- fun getProgress(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getProgress(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - var bundle = Bundle() - bundle.putDouble("duration", musicService.getDurationInSeconds()); - bundle.putDouble("position", musicService.getPositionInSeconds()); -@@ -622,8 +622,16 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM - } - - @ReactMethod -- fun getPlaybackState(callback: Promise) = scope.launch { -- if (verifyServiceBoundOrReject(callback)) return@launch -+ fun getPlaybackState(callback: Promise) = launchInScope { -+ if (verifyServiceBoundOrReject(callback)) return@launchInScope - callback.resolve(Arguments.fromBundle(musicService.getPlayerStateBundle(musicService.state))) - } -+ -+ // Bridgeless interop layer tries to pass the `Job` from `scope.launch` to the JS side -+ // which causes an exception. We can work around this using a wrapper. -+ private fun launchInScope(block: suspend () -> Unit) { -+ scope.launch { -+ block() -+ } -+ } - } -diff --git a/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt -index 9d6d869efcece065618d4f2cefdc8c54831af9ed..37f41fd3fcd8dcb7b5b54c0ad3f207ddeb6d961f 100644 ---- a/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt -+++ b/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt -@@ -741,9 +741,7 @@ class MusicService : HeadlessJsTaskService() { - - @MainThread - private fun emit(event: String, data: Bundle? = null) { -- reactNativeHost.reactInstanceManager.currentReactContext -- ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) -- ?.emit(event, data?.let { Arguments.fromBundle(it) }) -+ reactContext?.emitDeviceEvent(event, data?.let { Arguments.fromBundle(it) }) - } - - @MainThread -@@ -751,17 +749,16 @@ class MusicService : HeadlessJsTaskService() { - val payload = Arguments.createArray() - data.forEach { payload.pushMap(Arguments.fromBundle(it)) } - -- reactNativeHost.reactInstanceManager.currentReactContext -- ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) -- ?.emit(event, payload) -+ reactContext?.emitDeviceEvent(event, payload) - } - - override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig { - return HeadlessJsTaskConfig(TASK_KEY, Arguments.createMap(), 0, true) - } - -+ // https://github.com/doublesymmetry/react-native-track-player/pull/2451 - @MainThread - override fun onBind(intent: Intent): IBinder { - return binder - } - -diff --git a/ios/RNTrackPlayer/RNTrackPlayerBridge.m b/ios/RNTrackPlayer/RNTrackPlayerBridge.m -index 7741994191921ed86f7577f7ce589b3939d9dd49..776c09ec0ae450daa4dc9031ccd3ff4baba016d3 100644 ---- a/ios/RNTrackPlayer/RNTrackPlayerBridge.m -+++ b/ios/RNTrackPlayer/RNTrackPlayerBridge.m -@@ -151,17 +151,4 @@ RCT_EXTERN_METHOD(updateNowPlayingMetadata:(NSDictionary *)metadata - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject); - --RCT_EXTERN_METHOD(getSleepTimerProgress:(RCTPromiseResolveBlock)resolve -- rejecter:(RCTPromiseRejectBlock)reject); -- --RCT_EXTERN_METHOD(setSleepTimer:(double)time -- resolver:(RCTPromiseResolveBlock)resolve -- rejecter:(RCTPromiseRejectBlock)reject); -- --RCT_EXTERN_METHOD(sleepWhenActiveTrackReachesEnd:(RCTPromiseResolveBlock)resolve -- rejecter:(RCTPromiseRejectBlock)reject); -- --RCT_EXTERN_METHOD(clearSleepTimer:(RCTPromiseResolveBlock)resolve -- rejecter:(RCTPromiseRejectBlock)reject); -- - @end diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bae38e35b..7f6e27e91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,9 +71,6 @@ patchedDependencies: react-native-sheet-transitions: hash: 4db5ed53b31f4349d609d7dd2d0e053b6ee4c27e8d6d72c90ee74015ae18235f path: patches/react-native-sheet-transitions.patch - react-native-track-player@4.1.2: - hash: dc51df1dccb62feafc2369115ba8e02dba9b261d9cf397f4db6c57d6b4acefe5 - path: patches/react-native-track-player@4.1.2.patch workbox-precaching: hash: 51e57c78af317e292b43fca4b72f57b247b7b1a1faa8b03c8881ddb798c7c52f path: patches/workbox-precaching.patch @@ -1148,6 +1145,9 @@ importers: '@react-native-picker/picker': specifier: 2.11.4 version: 2.11.4(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7) + '@rntp/player': + specifier: 5.9.2 + version: 5.9.2(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7) '@shikijs/themes': specifier: 4.3.1 version: 4.3.1 @@ -1397,9 +1397,6 @@ importers: react-native-svg: specifier: 15.15.5 version: 15.15.5(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7) - react-native-track-player: - specifier: 4.1.2 - version: 4.1.2(patch_hash=dc51df1dccb62feafc2369115ba8e02dba9b261d9cf397f4db6c57d6b4acefe5)(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7) react-native-uikit-colors: specifier: 0.6.2 version: 0.6.2(nativewind@4.2.6(fae4c8694d5ef169f465873e7969eb18))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))) @@ -8621,6 +8618,16 @@ packages: resolution: {integrity: sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==} engines: {node: ^v12.20.0 || ^14.13.0 || >=16.0.0} + '@rntp/player@5.9.2': + resolution: {integrity: sha512-BckAAXu412xR6jWhZAG3aGS8qyFam0ZMmf3bnaeoi9YAUzVoa9gHWjMUEq3SzVs/udbn1wBoVep+/K0NjrTrCg==} + peerDependencies: + react: 19.2.7 + react-native: '*' + shaka-player: '>=4.12.0' + peerDependenciesMeta: + shaka-player: + optional: true + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -16959,19 +16966,6 @@ packages: react: 19.2.7 react-native: '*' - react-native-track-player@4.1.2: - resolution: {integrity: sha512-cIgMlqVJx/95hirUWPRW8CHxiBFj9Rjl/voKHh2jF/2URYMTQyt76t/m2FKvjeYUW2doKv4QSCBIOUmtyDLtJw==} - peerDependencies: - react: 19.2.7 - react-native: '>=0.60.0-rc.2' - react-native-windows: '>=0.63.0' - shaka-player: ^4.7.9 - peerDependenciesMeta: - react-native-windows: - optional: true - shaka-player: - optional: true - react-native-uikit-colors@0.6.2: resolution: {integrity: sha512-qcsQOb/0+3eoYPS/pJG8R5Idxnk+o/UKPwTfa4wCxJ/gFJEcShm1q0wsw9D7BgceXo8tK+aMDebeguvNJYt6Og==} peerDependencies: @@ -27619,6 +27613,11 @@ snapshots: '@reteps/dockerfmt@0.3.6': {} + '@rntp/player@5.9.2(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)': + dependencies: + react: 19.2.7 + react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7) + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -37475,11 +37474,6 @@ snapshots: react-native: 0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7) optional: true - react-native-track-player@4.1.2(patch_hash=dc51df1dccb62feafc2369115ba8e02dba9b261d9cf397f4db6c57d6b4acefe5)(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7): - dependencies: - react: 19.2.7 - react-native: 0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7) - react-native-uikit-colors@0.6.2(nativewind@4.2.6(fae4c8694d5ef169f465873e7969eb18))(react-native@0.86.0(@babel/core@7.29.0)(@react-native/metro-config@0.86.0(@babel/core@7.29.0)(bufferutil@4.1.0))(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.7))(react@19.2.7)(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.18))(@types/node@26.1.1)(typescript@6.0.3))): dependencies: apple-uikit-colors: 0.6.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ecfd26260..3ee7502ab 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -87,7 +87,6 @@ patchedDependencies: re-resizable@6.11.2: patches/re-resizable@6.11.2.patch react-native-ios-utilities@5.2.0: patches/react-native-ios-utilities@5.2.0.patch react-native-sheet-transitions: patches/react-native-sheet-transitions.patch - react-native-track-player@4.1.2: patches/react-native-track-player@4.1.2.patch workbox-precaching: patches/workbox-precaching.patch peerDependencyRules: