mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-16 20:51:43 +08:00
fix(mobile): upgrade audio player to avoid background service crashes
This commit is contained in:
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Generated
+18
-24
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user