mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
mpv init
This commit is contained in:
parent
967b90b98e
commit
19438ff1d5
13 changed files with 746 additions and 164 deletions
|
|
@ -246,6 +246,9 @@ dependencies {
|
|||
|
||||
// Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3
|
||||
implementation files("libs/lib-decoder-ffmpeg-release.aar")
|
||||
|
||||
// MPV Player library
|
||||
implementation files("libs/libmpv-release.aar")
|
||||
|
||||
// Google Cast Framework
|
||||
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class MainApplication : Application(), ReactApplication {
|
|||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
add(com.nuvio.app.mpv.MpvPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
|
|
|
|||
249
android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt
Normal file
249
android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.TextureView
|
||||
import dev.jdtech.mpv.MPVLib
|
||||
|
||||
class MPVView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MPVView"
|
||||
}
|
||||
|
||||
private var isMpvInitialized = false
|
||||
private var pendingDataSource: String? = null
|
||||
private var isPaused: Boolean = true
|
||||
private var surface: Surface? = null
|
||||
|
||||
// Event listener for React Native
|
||||
var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null
|
||||
var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null
|
||||
var onEndCallback: (() -> Unit)? = null
|
||||
var onErrorCallback: ((message: String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
surfaceTextureListener = this
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
Log.d(TAG, "Surface texture available: ${width}x${height}")
|
||||
try {
|
||||
surface = Surface(surfaceTexture)
|
||||
|
||||
MPVLib.create(context.applicationContext)
|
||||
initOptions()
|
||||
MPVLib.init()
|
||||
MPVLib.attachSurface(surface!!)
|
||||
MPVLib.addObserver(this)
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
|
||||
observeProperties()
|
||||
isMpvInitialized = true
|
||||
|
||||
// If a data source was set before surface was ready, load it now
|
||||
pendingDataSource?.let { url ->
|
||||
loadFile(url)
|
||||
pendingDataSource = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize MPV", e)
|
||||
onErrorCallback?.invoke("MPV initialization failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
Log.d(TAG, "Surface texture size changed: ${width}x${height}")
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
Log.d(TAG, "Surface texture destroyed")
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.removeObserver(this)
|
||||
MPVLib.detachSurface()
|
||||
MPVLib.destroy()
|
||||
isMpvInitialized = false
|
||||
}
|
||||
surface?.release()
|
||||
surface = null
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
// Called when the SurfaceTexture is updated via updateTexImage()
|
||||
}
|
||||
|
||||
private fun initOptions() {
|
||||
// Mobile-optimized profile
|
||||
MPVLib.setOptionString("profile", "fast")
|
||||
MPVLib.setOptionString("vo", "gpu")
|
||||
MPVLib.setOptionString("gpu-context", "android")
|
||||
MPVLib.setOptionString("opengl-es", "yes")
|
||||
MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy")
|
||||
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||
MPVLib.setOptionString("ao", "audiotrack,opensles")
|
||||
|
||||
// Network caching for streaming
|
||||
MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB
|
||||
MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB
|
||||
MPVLib.setOptionString("cache", "yes")
|
||||
MPVLib.setOptionString("cache-secs", "30")
|
||||
|
||||
// Disable terminal/input
|
||||
MPVLib.setOptionString("terminal", "no")
|
||||
MPVLib.setOptionString("input-default-bindings", "no")
|
||||
}
|
||||
|
||||
private fun observeProperties() {
|
||||
// MPV format constants (from MPVLib source)
|
||||
val MPV_FORMAT_NONE = 0
|
||||
val MPV_FORMAT_FLAG = 3
|
||||
val MPV_FORMAT_INT64 = 4
|
||||
val MPV_FORMAT_DOUBLE = 5
|
||||
|
||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("width", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("height", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("track-list", MPV_FORMAT_NONE)
|
||||
}
|
||||
|
||||
private fun loadFile(url: String) {
|
||||
Log.d(TAG, "Loading file: $url")
|
||||
MPVLib.command(arrayOf("loadfile", url))
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
fun setDataSource(url: String) {
|
||||
if (isMpvInitialized) {
|
||||
loadFile(url)
|
||||
} else {
|
||||
pendingDataSource = url
|
||||
}
|
||||
}
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
isPaused = paused
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyBoolean("pause", paused)
|
||||
}
|
||||
}
|
||||
|
||||
fun seekTo(positionSeconds: Double) {
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.setPropertyDouble("speed", speed)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Double) {
|
||||
if (isMpvInitialized) {
|
||||
// MPV volume is 0-100
|
||||
MPVLib.setPropertyDouble("volume", volume * 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
if (isMpvInitialized) {
|
||||
if (trackId == -1) {
|
||||
MPVLib.setPropertyString("aid", "no")
|
||||
} else {
|
||||
MPVLib.setPropertyInt("aid", trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
if (isMpvInitialized) {
|
||||
if (trackId == -1) {
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
} else {
|
||||
MPVLib.setPropertyInt("sid", trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MPVLib.EventObserver implementation
|
||||
|
||||
override fun eventProperty(property: String) {
|
||||
Log.d(TAG, "Property changed: $property")
|
||||
when (property) {
|
||||
"track-list" -> {
|
||||
// Track list updated, could notify JS about available tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Long) {
|
||||
Log.d(TAG, "Property $property = $value (Long)")
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
Log.d(TAG, "Property $property = $value (Double)")
|
||||
when (property) {
|
||||
"time-pos" -> {
|
||||
val duration = MPVLib.getPropertyDouble("duration") ?: 0.0
|
||||
onProgressCallback?.invoke(value, duration)
|
||||
}
|
||||
"duration" -> {
|
||||
val width = MPVLib.getPropertyInt("width") ?: 0
|
||||
val height = MPVLib.getPropertyInt("height") ?: 0
|
||||
onLoadCallback?.invoke(value, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
Log.d(TAG, "Property $property = $value (Boolean)")
|
||||
when (property) {
|
||||
"eof-reached" -> {
|
||||
if (value) {
|
||||
onEndCallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: String) {
|
||||
Log.d(TAG, "Property $property = $value (String)")
|
||||
}
|
||||
|
||||
override fun event(eventId: Int) {
|
||||
Log.d(TAG, "Event: $eventId")
|
||||
// MPV event constants (from MPVLib source)
|
||||
val MPV_EVENT_FILE_LOADED = 8
|
||||
val MPV_EVENT_END_FILE = 7
|
||||
|
||||
when (eventId) {
|
||||
MPV_EVENT_FILE_LOADED -> {
|
||||
// File is loaded, start playback if not paused
|
||||
if (!isPaused) {
|
||||
MPVLib.setPropertyBoolean("pause", false)
|
||||
}
|
||||
}
|
||||
MPV_EVENT_END_FILE -> {
|
||||
onEndCallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt
Normal file
16
android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class MpvPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return listOf(MpvPlayerViewManager(reactContext))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.nuvio.app.mpv
|
||||
|
||||
import android.graphics.Color
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.common.MapBuilder
|
||||
import com.facebook.react.uimanager.SimpleViewManager
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.uimanager.annotations.ReactProp
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter
|
||||
|
||||
class MpvPlayerViewManager(
|
||||
private val reactContext: ReactApplicationContext
|
||||
) : SimpleViewManager<MPVView>() {
|
||||
|
||||
companion object {
|
||||
const val REACT_CLASS = "MpvPlayer"
|
||||
|
||||
// Commands
|
||||
const val COMMAND_SEEK = 1
|
||||
const val COMMAND_SET_AUDIO_TRACK = 2
|
||||
const val COMMAND_SET_SUBTITLE_TRACK = 3
|
||||
}
|
||||
|
||||
override fun getName(): String = REACT_CLASS
|
||||
|
||||
override fun createViewInstance(context: ThemedReactContext): MPVView {
|
||||
val view = MPVView(context)
|
||||
// Note: Do NOT set background color - it will block the SurfaceView content
|
||||
|
||||
// Set up event callbacks
|
||||
view.onLoadCallback = { duration, width, height ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putDouble("duration", duration)
|
||||
putInt("width", width)
|
||||
putInt("height", height)
|
||||
}
|
||||
sendEvent(context, view.id, "onLoad", event)
|
||||
}
|
||||
|
||||
view.onProgressCallback = { position, duration ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putDouble("currentTime", position)
|
||||
putDouble("duration", duration)
|
||||
}
|
||||
sendEvent(context, view.id, "onProgress", event)
|
||||
}
|
||||
|
||||
view.onEndCallback = {
|
||||
sendEvent(context, view.id, "onEnd", Arguments.createMap())
|
||||
}
|
||||
|
||||
view.onErrorCallback = { message ->
|
||||
val event = Arguments.createMap().apply {
|
||||
putString("error", message)
|
||||
}
|
||||
sendEvent(context, view.id, "onError", event)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) {
|
||||
context.getJSModule(RCTEventEmitter::class.java)
|
||||
.receiveEvent(viewId, eventName, params)
|
||||
}
|
||||
|
||||
override fun getExportedCustomBubblingEventTypeConstants(): Map<String, Any> {
|
||||
return MapBuilder.builder<String, Any>()
|
||||
.put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad")))
|
||||
.put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress")))
|
||||
.put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd")))
|
||||
.put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError")))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getCommandsMap(): Map<String, Int> {
|
||||
return MapBuilder.of(
|
||||
"seek", COMMAND_SEEK,
|
||||
"setAudioTrack", COMMAND_SET_AUDIO_TRACK,
|
||||
"setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK
|
||||
)
|
||||
}
|
||||
|
||||
override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) {
|
||||
when (commandId) {
|
||||
"seek" -> {
|
||||
args?.getDouble(0)?.let { view.seekTo(it) }
|
||||
}
|
||||
"setAudioTrack" -> {
|
||||
args?.getInt(0)?.let { view.setAudioTrack(it) }
|
||||
}
|
||||
"setSubtitleTrack" -> {
|
||||
args?.getInt(0)?.let { view.setSubtitleTrack(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React Props
|
||||
|
||||
@ReactProp(name = "source")
|
||||
fun setSource(view: MPVView, source: String?) {
|
||||
source?.let { view.setDataSource(it) }
|
||||
}
|
||||
|
||||
@ReactProp(name = "paused")
|
||||
fun setPaused(view: MPVView, paused: Boolean) {
|
||||
view.setPaused(paused)
|
||||
}
|
||||
|
||||
@ReactProp(name = "volume", defaultFloat = 1.0f)
|
||||
fun setVolume(view: MPVView, volume: Float) {
|
||||
view.setVolume(volume.toDouble())
|
||||
}
|
||||
|
||||
@ReactProp(name = "rate", defaultFloat = 1.0f)
|
||||
fun setRate(view: MPVView, rate: Float) {
|
||||
view.setSpeed(rate.toDouble())
|
||||
}
|
||||
|
||||
// Handle backgroundColor prop to prevent crash from React Native style system
|
||||
@ReactProp(name = "backgroundColor", customType = "Color")
|
||||
fun setBackgroundColor(view: MPVView, color: Int?) {
|
||||
// Intentionally ignoring - background color would block the TextureView content
|
||||
// Leave the view transparent
|
||||
}
|
||||
}
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
|
|
@ -42,6 +42,7 @@
|
|||
"expo-auth-session": "~7.0.8",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-brightness": "~14.0.7",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-crypto": "~15.0.7",
|
||||
"expo-dev-client": "~6.0.15",
|
||||
"expo-device": "~8.0.9",
|
||||
|
|
@ -53,6 +54,7 @@
|
|||
"expo-libvlc-player": "^2.2.3",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-navigation-bar": "~5.0.10",
|
||||
"expo-notifications": "~0.32.12",
|
||||
"expo-random": "^14.0.1",
|
||||
"expo-screen-orientation": "~9.0.7",
|
||||
|
|
@ -6326,6 +6328,17 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-clipboard": {
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
|
||||
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
||||
|
|
@ -6590,6 +6603,22 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-navigation-bar": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-5.0.10.tgz",
|
||||
"integrity": "sha512-r9rdLw8mY6GPMQmVVOY/r1NBBw74DZefXHF60HxhRsdNI2kjc1wLdfWfR2rk4JVdOvdMDujnGrc9HQmqM3n8Jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-native/normalize-colors": "0.81.5",
|
||||
"debug": "^4.3.2",
|
||||
"react-native-is-edge-to-edge": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-notifications": {
|
||||
"version": "0.32.15",
|
||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.15.tgz",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"expo-auth-session": "~7.0.8",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-brightness": "~14.0.7",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-crypto": "~15.0.7",
|
||||
"expo-dev-client": "~6.0.15",
|
||||
"expo-device": "~8.0.9",
|
||||
|
|
@ -53,6 +54,7 @@
|
|||
"expo-libvlc-player": "^2.2.3",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-navigation-bar": "~5.0.10",
|
||||
"expo-notifications": "~0.32.12",
|
||||
"expo-random": "^14.0.1",
|
||||
"expo-screen-orientation": "~9.0.7",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { View, StyleSheet, Platform, Animated } from 'react-native';
|
||||
import { toast } from '@backpackapp-io/react-native-toast';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
|
|
@ -349,8 +350,35 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
if (modals.showEpisodeStreamsModal) return;
|
||||
playerState.setPaused(true);
|
||||
}}
|
||||
onError={(err) => {
|
||||
onError={(err: any) => {
|
||||
logger.error('Video Error', err);
|
||||
|
||||
// Check for decoding errors to switch to VLC
|
||||
const errorString = err?.errorString || err?.error?.errorString;
|
||||
const errorCode = err?.errorCode || err?.error?.errorCode;
|
||||
const causeMessage = err?.error?.cause?.message;
|
||||
|
||||
const isDecodingError =
|
||||
(errorString && errorString.includes('ERROR_CODE_DECODING_FAILED')) ||
|
||||
errorCode === '24003' ||
|
||||
(causeMessage && causeMessage.includes('MediaCodecVideoRenderer error'));
|
||||
|
||||
if (!useVLC && isDecodingError) {
|
||||
const toastId = toast.loading('Decoding error. Switching to VLC Player...');
|
||||
setTimeout(() => toast.dismiss(toastId), 3000);
|
||||
|
||||
// We can just show a normal toast or use the existing modal system if we want,
|
||||
// but checking the file imports, I don't see Toast imported.
|
||||
// Let's implement the navigation replace.
|
||||
|
||||
// Using a simple navigation replace to force VLC
|
||||
(navigation as any).replace('PlayerAndroid', {
|
||||
...route.params,
|
||||
forceVlc: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
modals.setErrorDetails(JSON.stringify(err));
|
||||
modals.setShowErrorModal(true);
|
||||
}}
|
||||
|
|
|
|||
117
src/components/player/android/MpvPlayer.tsx
Normal file
117
src/components/player/android/MpvPlayer.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
|
||||
import { View, StyleSheet, requireNativeComponent, Platform, UIManager, findNodeHandle } from 'react-native';
|
||||
|
||||
// Only available on Android
|
||||
const MpvPlayerNative = Platform.OS === 'android'
|
||||
? requireNativeComponent<any>('MpvPlayer')
|
||||
: null;
|
||||
|
||||
export interface MpvPlayerRef {
|
||||
seek: (positionSeconds: number) => void;
|
||||
setAudioTrack: (trackId: number) => void;
|
||||
setSubtitleTrack: (trackId: number) => void;
|
||||
}
|
||||
|
||||
export interface MpvPlayerProps {
|
||||
source: string;
|
||||
paused?: boolean;
|
||||
volume?: number;
|
||||
rate?: number;
|
||||
style?: any;
|
||||
onLoad?: (data: { duration: number; width: number; height: number }) => void;
|
||||
onProgress?: (data: { currentTime: number; duration: number }) => void;
|
||||
onEnd?: () => void;
|
||||
onError?: (error: { error: string }) => void;
|
||||
}
|
||||
|
||||
const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
|
||||
const nativeRef = useRef<any>(null);
|
||||
|
||||
const dispatchCommand = useCallback((commandName: string, args: any[] = []) => {
|
||||
if (nativeRef.current && Platform.OS === 'android') {
|
||||
const handle = findNodeHandle(nativeRef.current);
|
||||
if (handle) {
|
||||
UIManager.dispatchViewManagerCommand(
|
||||
handle,
|
||||
commandName,
|
||||
args
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek: (positionSeconds: number) => {
|
||||
dispatchCommand('seek', [positionSeconds]);
|
||||
},
|
||||
setAudioTrack: (trackId: number) => {
|
||||
dispatchCommand('setAudioTrack', [trackId]);
|
||||
},
|
||||
setSubtitleTrack: (trackId: number) => {
|
||||
dispatchCommand('setSubtitleTrack', [trackId]);
|
||||
},
|
||||
}), [dispatchCommand]);
|
||||
|
||||
if (Platform.OS !== 'android' || !MpvPlayerNative) {
|
||||
// Fallback for iOS or if native component is not available
|
||||
return (
|
||||
<View style={[styles.container, props.style, { backgroundColor: 'black' }]} />
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[MpvPlayer] Rendering native component with:', {
|
||||
source: props.source?.substring(0, 50) + '...',
|
||||
paused: props.paused ?? true,
|
||||
volume: props.volume ?? 1.0,
|
||||
rate: props.rate ?? 1.0,
|
||||
});
|
||||
|
||||
const handleLoad = (event: any) => {
|
||||
console.log('[MpvPlayer] Native onLoad event:', event?.nativeEvent);
|
||||
props.onLoad?.(event?.nativeEvent);
|
||||
};
|
||||
|
||||
const handleProgress = (event: any) => {
|
||||
const data = event?.nativeEvent;
|
||||
if (data && Math.floor(data.currentTime) % 5 === 0) {
|
||||
console.log('[MpvPlayer] Native onProgress event:', data);
|
||||
}
|
||||
props.onProgress?.(data);
|
||||
};
|
||||
|
||||
const handleEnd = (event: any) => {
|
||||
console.log('[MpvPlayer] Native onEnd event');
|
||||
props.onEnd?.();
|
||||
};
|
||||
|
||||
const handleError = (event: any) => {
|
||||
console.log('[MpvPlayer] Native onError event:', event?.nativeEvent);
|
||||
props.onError?.(event?.nativeEvent);
|
||||
};
|
||||
|
||||
return (
|
||||
<MpvPlayerNative
|
||||
ref={nativeRef}
|
||||
style={[styles.container, props.style]}
|
||||
source={props.source}
|
||||
paused={props.paused ?? true}
|
||||
volume={props.volume ?? 1.0}
|
||||
rate={props.rate ?? 1.0}
|
||||
onLoad={handleLoad}
|
||||
onProgress={handleProgress}
|
||||
onEnd={handleEnd}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'black',
|
||||
},
|
||||
});
|
||||
|
||||
MpvPlayer.displayName = 'MpvPlayer';
|
||||
|
||||
export default MpvPlayer;
|
||||
|
|
@ -1,52 +1,18 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
||||
import Video, { ViewType, VideoRef, ResizeMode } from 'react-native-video';
|
||||
import VlcVideoPlayer, { VlcPlayerRef } from '../../VlcVideoPlayer';
|
||||
import React from 'react';
|
||||
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
|
||||
import { PinchGestureHandler } from 'react-native-gesture-handler';
|
||||
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
|
||||
import { styles } from '../../utils/playerStyles';
|
||||
import { ResizeModeType } from '../../utils/playerTypes';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes';
|
||||
|
||||
const getVideoResizeMode = (resizeMode: ResizeModeType) => {
|
||||
switch (resizeMode) {
|
||||
case 'contain': return 'contain';
|
||||
case 'cover': return 'cover';
|
||||
case 'stretch': return 'contain';
|
||||
case 'none': return 'contain';
|
||||
default: return 'contain';
|
||||
}
|
||||
};
|
||||
|
||||
// VLC only supports 'contain' | 'cover' | 'none'
|
||||
const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => {
|
||||
switch (resizeMode) {
|
||||
case 'contain': return 'contain';
|
||||
case 'cover': return 'cover';
|
||||
case 'stretch': return 'cover'; // stretch is not supported, use cover
|
||||
case 'none': return 'none';
|
||||
default: return 'contain';
|
||||
}
|
||||
};
|
||||
|
||||
interface VideoSurfaceProps {
|
||||
useVLC: boolean;
|
||||
forceVlcRemount: boolean;
|
||||
processedStreamUrl: string;
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
zoomScale: number;
|
||||
resizeMode: ResizeModeType;
|
||||
paused: boolean;
|
||||
currentStreamUrl: string;
|
||||
headers: any;
|
||||
videoType: any;
|
||||
vlcSelectedAudioTrack?: number;
|
||||
vlcSelectedSubtitleTrack?: number;
|
||||
vlcRestoreTime?: number;
|
||||
vlcKey: string;
|
||||
selectedAudioTrack: any;
|
||||
selectedTextTrack: any;
|
||||
useCustomSubtitles: boolean;
|
||||
|
||||
// Callbacks
|
||||
toggleControls: () => void;
|
||||
|
|
@ -56,44 +22,45 @@ interface VideoSurfaceProps {
|
|||
onEnd: () => void;
|
||||
onError: (err: any) => void;
|
||||
onBuffer: (buf: any) => void;
|
||||
onTracksUpdate: (tracks: any) => void;
|
||||
|
||||
// Refs
|
||||
vlcPlayerRef: React.RefObject<VlcPlayerRef>;
|
||||
videoRef: React.RefObject<VideoRef>;
|
||||
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
|
||||
pinchRef: any;
|
||||
|
||||
// Handlers
|
||||
onPinchGestureEvent: any;
|
||||
onPinchHandlerStateChange: any;
|
||||
vlcLoadedRef: React.MutableRefObject<boolean>;
|
||||
screenDimensions: { width: number, height: number };
|
||||
customVideoStyles: any;
|
||||
|
||||
// Debugging
|
||||
loadStartAtRef: React.MutableRefObject<number | null>;
|
||||
firstFrameAtRef: React.MutableRefObject<number | null>;
|
||||
// Legacy props (kept for compatibility but unused with MPV)
|
||||
useVLC?: boolean;
|
||||
forceVlcRemount?: boolean;
|
||||
headers?: any;
|
||||
videoType?: any;
|
||||
vlcSelectedAudioTrack?: number;
|
||||
vlcSelectedSubtitleTrack?: number;
|
||||
vlcRestoreTime?: number;
|
||||
vlcKey?: string;
|
||||
selectedAudioTrack?: any;
|
||||
selectedTextTrack?: any;
|
||||
useCustomSubtitles?: boolean;
|
||||
onTracksUpdate?: (tracks: any) => void;
|
||||
vlcPlayerRef?: any;
|
||||
videoRef?: any;
|
||||
vlcLoadedRef?: any;
|
||||
customVideoStyles?: any;
|
||||
loadStartAtRef?: any;
|
||||
firstFrameAtRef?: any;
|
||||
zoomScale?: number;
|
||||
}
|
||||
|
||||
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||
useVLC,
|
||||
forceVlcRemount,
|
||||
processedStreamUrl,
|
||||
volume,
|
||||
playbackSpeed,
|
||||
zoomScale,
|
||||
resizeMode,
|
||||
paused,
|
||||
currentStreamUrl,
|
||||
headers,
|
||||
videoType,
|
||||
vlcSelectedAudioTrack,
|
||||
vlcSelectedSubtitleTrack,
|
||||
vlcRestoreTime,
|
||||
vlcKey,
|
||||
selectedAudioTrack,
|
||||
selectedTextTrack,
|
||||
useCustomSubtitles,
|
||||
toggleControls,
|
||||
onLoad,
|
||||
onProgress,
|
||||
|
|
@ -101,23 +68,57 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
onEnd,
|
||||
onError,
|
||||
onBuffer,
|
||||
onTracksUpdate,
|
||||
vlcPlayerRef,
|
||||
videoRef,
|
||||
mpvPlayerRef,
|
||||
pinchRef,
|
||||
onPinchGestureEvent,
|
||||
onPinchHandlerStateChange,
|
||||
vlcLoadedRef,
|
||||
screenDimensions,
|
||||
customVideoStyles,
|
||||
loadStartAtRef,
|
||||
firstFrameAtRef
|
||||
}) => {
|
||||
// Use the actual stream URL
|
||||
const streamUrl = currentStreamUrl || processedStreamUrl;
|
||||
|
||||
const isHlsStream = (url: string) => {
|
||||
return url.includes('.m3u8') || url.includes('m3u8') ||
|
||||
url.includes('hls') || url.includes('playlist') ||
|
||||
(videoType && videoType.toLowerCase() === 'm3u8');
|
||||
console.log('[VideoSurface] Rendering with:', {
|
||||
streamUrl: streamUrl?.substring(0, 50) + '...',
|
||||
paused,
|
||||
volume,
|
||||
playbackSpeed,
|
||||
screenDimensions,
|
||||
});
|
||||
|
||||
const handleLoad = (data: { duration: number; width: number; height: number }) => {
|
||||
console.log('[VideoSurface] onLoad received:', data);
|
||||
onLoad({
|
||||
duration: data.duration,
|
||||
naturalSize: {
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleProgress = (data: { currentTime: number; duration: number }) => {
|
||||
// Log every 5 seconds to avoid spam
|
||||
if (Math.floor(data.currentTime) % 5 === 0) {
|
||||
console.log('[VideoSurface] onProgress:', data);
|
||||
}
|
||||
onProgress({
|
||||
currentTime: data.currentTime,
|
||||
playableDuration: data.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = (error: { error: string }) => {
|
||||
console.log('[VideoSurface] onError received:', error);
|
||||
onError({
|
||||
error: {
|
||||
errorString: error.error,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
console.log('[VideoSurface] onEnd received');
|
||||
onEnd();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -125,96 +126,53 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
}]}>
|
||||
{/* MPV Player - rendered at the bottom of the z-order */}
|
||||
<MpvPlayer
|
||||
ref={mpvPlayerRef}
|
||||
source={streamUrl}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
style={localStyles.player}
|
||||
onLoad={handleLoad}
|
||||
onProgress={handleProgress}
|
||||
onEnd={handleEnd}
|
||||
onError={handleError}
|
||||
/>
|
||||
|
||||
{/* Gesture overlay - transparent, on top of the player */}
|
||||
<PinchGestureHandler
|
||||
ref={pinchRef}
|
||||
onGestureEvent={onPinchGestureEvent}
|
||||
onHandlerStateChange={onPinchHandlerStateChange}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
style={{ flex: 1 }}
|
||||
activeOpacity={1}
|
||||
onPress={toggleControls}
|
||||
>
|
||||
{useVLC && !forceVlcRemount ? (
|
||||
<VlcVideoPlayer
|
||||
ref={vlcPlayerRef}
|
||||
source={processedStreamUrl}
|
||||
volume={volume}
|
||||
playbackSpeed={playbackSpeed}
|
||||
zoomScale={zoomScale}
|
||||
resizeMode={getVlcResizeMode(resizeMode)}
|
||||
onLoad={(data) => {
|
||||
vlcLoadedRef.current = true;
|
||||
onLoad(data);
|
||||
if (!paused && vlcPlayerRef.current) {
|
||||
setTimeout(() => {
|
||||
if (vlcPlayerRef.current) {
|
||||
vlcPlayerRef.current.play();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
onProgress={onProgress}
|
||||
onSeek={onSeek}
|
||||
onEnd={onEnd}
|
||||
onError={onError}
|
||||
onTracksUpdate={onTracksUpdate}
|
||||
selectedAudioTrack={vlcSelectedAudioTrack}
|
||||
selectedSubtitleTrack={vlcSelectedSubtitleTrack}
|
||||
restoreTime={vlcRestoreTime}
|
||||
forceRemount={forceVlcRemount}
|
||||
key={vlcKey}
|
||||
/>
|
||||
) : (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={[styles.video, customVideoStyles]}
|
||||
source={{
|
||||
uri: currentStreamUrl,
|
||||
headers: headers,
|
||||
type: isHlsStream(currentStreamUrl) ? 'm3u8' : videoType
|
||||
}}
|
||||
paused={paused}
|
||||
onLoadStart={() => {
|
||||
loadStartAtRef.current = Date.now();
|
||||
}}
|
||||
onProgress={onProgress}
|
||||
onLoad={onLoad}
|
||||
onReadyForDisplay={() => {
|
||||
firstFrameAtRef.current = Date.now();
|
||||
}}
|
||||
onSeek={onSeek}
|
||||
onEnd={onEnd}
|
||||
onError={onError}
|
||||
onBuffer={onBuffer}
|
||||
resizeMode={getVideoResizeMode(resizeMode)}
|
||||
selectedAudioTrack={selectedAudioTrack || undefined}
|
||||
selectedTextTrack={useCustomSubtitles ? { type: 'disabled' } as any : (selectedTextTrack >= 0 ? { type: 'index', value: selectedTextTrack } as any : undefined)}
|
||||
rate={playbackSpeed}
|
||||
volume={volume}
|
||||
muted={false}
|
||||
repeat={false}
|
||||
playInBackground={false}
|
||||
playWhenInactive={false}
|
||||
ignoreSilentSwitch="ignore"
|
||||
mixWithOthers="inherit"
|
||||
progressUpdateInterval={500}
|
||||
disableFocus={true}
|
||||
allowsExternalPlayback={false}
|
||||
preventsDisplaySleepDuringVideoPlayback={true}
|
||||
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View style={localStyles.gestureOverlay} pointerEvents="box-only">
|
||||
<TouchableWithoutFeedback onPress={toggleControls}>
|
||||
<View style={localStyles.touchArea} />
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</PinchGestureHandler>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const localStyles = StyleSheet.create({
|
||||
player: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
gestureOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
touchArea: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
|
||||
import RNImmersiveMode from 'react-native-immersive-mode';
|
||||
import * as NavigationBar from 'expo-navigation-bar';
|
||||
import * as Brightness from 'expo-brightness';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
|
@ -18,19 +19,34 @@ export const usePlayerSetup = (
|
|||
const originalSystemBrightnessModeRef = useRef<number | null>(null);
|
||||
const isAppBackgrounded = useRef(false);
|
||||
|
||||
const enableImmersiveMode = () => {
|
||||
const enableImmersiveMode = async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
// Standard immersive mode
|
||||
RNImmersiveMode.setBarTranslucent(true);
|
||||
RNImmersiveMode.fullLayout(true);
|
||||
StatusBar.setHidden(true, 'none');
|
||||
|
||||
// Explicitly hide bottom navigation bar using Expo
|
||||
try {
|
||||
await NavigationBar.setVisibilityAsync("hidden");
|
||||
await NavigationBar.setBehaviorAsync("overlay-swipe");
|
||||
} catch (e) {
|
||||
// Ignore errors on non-supported devices
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const disableImmersiveMode = () => {
|
||||
const disableImmersiveMode = async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
RNImmersiveMode.setBarTranslucent(false);
|
||||
RNImmersiveMode.fullLayout(false);
|
||||
StatusBar.setHidden(false, 'fade');
|
||||
|
||||
try {
|
||||
await NavigationBar.setVisibilityAsync("visible");
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import * as ExpoClipboard from 'expo-clipboard';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
|
|
@ -21,6 +22,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
errorDetails,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const { width } = useWindowDimensions();
|
||||
const MODAL_WIDTH = Math.min(width * 0.8, 400);
|
||||
|
||||
|
|
@ -31,6 +33,12 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
await ExpoClipboard.setStringAsync(errorDetails);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (!showErrorModal) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -74,16 +82,42 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
Playback Error
|
||||
</Text>
|
||||
|
||||
<Text style={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
lineHeight: 22
|
||||
}}>
|
||||
<Text
|
||||
numberOfLines={3}
|
||||
ellipsizeMode="tail"
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
lineHeight: 22
|
||||
}}
|
||||
>
|
||||
{errorDetails || 'An unknown error occurred during playback.'}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleCopy}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 8,
|
||||
marginBottom: 24,
|
||||
opacity: 0.8
|
||||
}}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={copied ? "check" : "content-copy"}
|
||||
size={16}
|
||||
color="rgba(255,255,255,0.6)"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}>
|
||||
{copied ? 'Copied to clipboard' : 'Copy error details'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
|
|
|
|||
Loading…
Reference in a new issue