Compare commits

..

3 Commits

Author SHA1 Message Date
Gauvain
a5daa5740e chore(i18n): remove common.select key orphaned by TrackSheet removal
common.select was only referenced by TrackSheet (removed in this PR);
the i18n:check CI flagged it as unused. en.json only — Crowdin handles
the other locales.
2026-06-29 23:19:13 +02:00
Gauvain
3950c01513 chore: remove unused TrackSheet component
TrackSheet (added in #968 'replace content item dropdowns with sheets')
is dead code: no imports or usages anywhere in the codebase. FilterSheet
covers the track-selection use case. Removing to drop dead weight.
2026-06-29 22:54:18 +02:00
Gauvain
97b6a912e0 refactor: migrate JS/MJS sources and scripts to TypeScript (#1717)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
2026-06-29 19:48:32 +02:00
20 changed files with 259 additions and 412 deletions

View File

@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Coding Standards ## Coding Standards
- Use TypeScript for ALL files (no .js files) - Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use descriptive English names for variables, functions, and components - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management

View File

@@ -30,7 +30,7 @@ jobs:
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 🔍 Detect duplicate issues - name: 🔍 Detect duplicate issues
run: bun scripts/detect-duplicate-issue.mjs run: bun scripts/detect-duplicate-issue.ts
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}

4
.gitignore vendored
View File

@@ -12,10 +12,6 @@ web-build/
# Platform-specific Build Directories # Platform-specific Build Directories
/ios /ios
/android /android
/iostv
/iosmobile
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects) # Gradle caches (top-level + per-module native projects)
**/.gradle/ **/.gradle/

View File

@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
## Coding Standards ## Coding Standards
- Use TypeScript for all files (no .js) - Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
- Use functional React components with hooks - Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state - Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings) - Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)

View File

@@ -1,99 +0,0 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { FilterSheet } from "./filters/FilterSheet";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
onChange: (value: number) => void;
selected?: number | undefined;
streamType?: string;
title: string;
}
export const TrackSheet: React.FC<Props> = ({
source,
onChange,
selected,
streamType,
title,
...props
}) => {
const isTv = Platform.isTV;
const { t } = useTranslation();
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source, streamType],
);
const selectedSteam = useMemo(
() => streams?.find((x) => x.Index === selected),
[streams, selected],
);
const noneOption = useMemo(
() => ({ Index: -1, DisplayTitle: t("common.none") }),
[t],
);
// Creates a modified data array that includes a "None" option for subtitles
// We might want to possibly do this for other places, like audio?
const addNoneToSubtitles = useMemo(() => {
if (streamType === "Subtitle") {
const result = streams ? [noneOption, ...streams] : [noneOption];
return result;
}
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false);
if (isTv || (streams && streams.length === 0)) return null;
return (
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select")}
</Text>
</TouchableOpacity>
</View>
<FilterSheet
open={open}
setOpen={setOpen}
title={title}
data={addNoneToSubtitles || []}
values={
selected === -1 && streamType === "Subtitle"
? [{ Index: -1, DisplayTitle: t("common.none") }]
: selectedSteam
? [selectedSteam]
: []
}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).DisplayTitle || "";
return label.toLowerCase().includes(query.toLowerCase());
}}
renderItemLabel={(item) => (
<Text>{(item as any).DisplayTitle || ""}</Text>
)}
set={(vals) => {
const chosen = vals[0] as any;
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
onChange(chosen.Index);
}
}}
/>
</View>
);
};

View File

@@ -3,9 +3,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
export default { const MediaTypes = {
Audio: "Audio", Audio: "Audio",
Video: "Video", Video: "Video",
Photo: "Photo", Photo: "Photo",
Book: "Book", Book: "Book",
}; } as const;
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
export default MediaTypes;

View File

View File

@@ -2,12 +2,14 @@ package expo.modules.mpvplayer
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.TextureView
import android.view.SurfaceView import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import expo.modules.kotlin.AppContext import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
@@ -28,26 +30,15 @@ data class VideoLoadConfig(
val cacheEnabled: String? = null, val cacheEnabled: String? = null,
val cacheSeconds: Int? = null, val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null, val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null, val demuxerMaxBackBytes: Int? = null
) )
/** /**
* MpvPlayerView - ExpoView that hosts the MPV player. * MpvPlayerView - ExpoView that hosts the MPV player.
* * Uses TextureView for reliable Picture-in-Picture support.
* Uses SurfaceView (not TextureView) so the surface routes directly to
* SurfaceFlinger (the OS compositor) rather than compositing into the
* app's window surface. This matches mpv-android's architecture and
* gives mpv a standalone surface.
*
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
* recreated on PiP entry/exit, and the new surface's initial dimensions
* can be stale until the next layout pass. We push dimension updates to
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
* OnLayoutChangeListener, so the PiP transition (which fires layout
* passes on the view itself) reaches mpv promptly.
*/ */
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback { MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object { companion object {
private const val TAG = "MpvPlayerView" private const val TAG = "MpvPlayerView"
@@ -61,7 +52,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher() val onPictureInPictureChange by EventDispatcher()
private var surfaceView: SurfaceView private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null private var pipController: PiPController? = null
@@ -72,45 +63,31 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var surfaceReady: Boolean = false private var surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var activeSurface: Surface? = null private var activeSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking // PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper()) private val pipHandler = Handler(Looper.getMainLooper())
init { init {
setBackgroundColor(Color.BLACK) setBackgroundColor(Color.BLACK)
// SurfaceView for video rendering. Routes the surface directly to // Create TextureView for video rendering (composites into app window for PiP support)
// SurfaceFlinger (the OS compositor), giving mpv a standalone textureView = TextureView(context).apply {
// surface. TextureView composites into the app's window surface
// which is less efficient and breaks PiP transitions.
surfaceView = SurfaceView(context).apply {
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
surfaceTextureListener = this@MpvPlayerView
} }
surfaceView.holder.addCallback(this@MpvPlayerView) addView(textureView)
addView(surfaceView)
// Push dimension updates to mpv on every view bounds change. This
// is the primary PiP black-screen fix: entering PiP fires a layout
// pass on the SurfaceView itself, and we proactively tell mpv the
// new size so it resizes its EGL swapchain before rendering.
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom ->
val w = right - left
val h = bottom - top
val oldW = oldRight - oldLeft
val oldH = oldBottom - oldTop
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
renderer?.updateSurfaceSize(w, h)
}
}
// Initialize PiP controller with Expo's AppContext for proper activity access // Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext) pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
play() play()
@@ -126,17 +103,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) { override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) { if (isInPiP) {
// Post size syncs after the PiP layout settles. Two passes if (!isWaitingForPiPTransition) {
// catch both the immediate surface re-attach and the isWaitingForPiPTransition = true
// post-animation layout pass. Replaces the old TextureView pipHandler.removeCallbacksAndMessages(null)
// measure/layout polling hack (forcePiPBufferSize). for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.removeCallbacksAndMessages(null) pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) }
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500) }
} else { } else {
// Restore from PiP: surface resized back to fullscreen. isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100) restoreFromPiP()
} }
onPictureInPictureChange(mapOf("isActive" to isInPiP)) onPictureInPictureChange(mapOf("isActive" to isInPiP))
} }
@@ -149,7 +126,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
/** /**
* Start the renderer with the given VO driver. * Start the renderer with the given VO driver.
* Called lazily on first loadVideo so user settings are available. * Called lazily on first loadVideo so the voDriver setting is available.
*/ */
private fun ensureRendererStarted(voDriver: String?) { private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return if (rendererStarted) return
@@ -158,14 +135,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next") renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true rendererStarted = true
// If the surface is already alive (surfaceCreated fired before pendingSurface?.let { surface ->
// loadVideo), attach it now. With SurfaceView the surface is
// owned by the holder, so we read it from there directly rather
// than stashing it on the side.
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
activeSurface = surface activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
syncSurfaceSizeToView() pendingSurface = null
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}") Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -173,20 +146,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
// MARK: - SurfaceHolder.Callback // MARK: - TextureView.SurfaceTextureListener
override fun surfaceCreated(holder: SurfaceHolder) { override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
val surface = holder.surface this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true surfaceReady = true
if (rendererStarted) { if (rendererStarted) {
// The previous Surface reference is holder-owned; do NOT release // Release the previous wrapper Surface before losing the only
// it (SurfaceView manages its lifecycle). Just track the new one. // reference to it. cleanup() only runs on detach, so without this
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
activeSurface = surface activeSurface = surface
renderer?.attachSurface(surface) renderer?.attachSurface(surface)
// Push the actual view dimensions immediately so mpv doesn't } else {
// render against stale full-screen geometry during PiP transitions. pendingSurface = surface
syncSurfaceSizeToView()
} }
// If we have a pending load, execute it now // If we have a pending load, execute it now
@@ -197,36 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
} }
} }
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
if (width > 0 && height > 0) { surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height) renderer?.updateSurfaceSize(width, height)
}
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
surfaceReady = false surfaceReady = false
renderer?.detachSurface() renderer?.detachSurface()
// Do NOT issue mpv "stop" here. Playback continues against the return false // mpv manages the SurfaceTexture
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
// background/foreground), we re-attach and frames resume. This
// matches the keep-open=always setting in MPVLayerRenderer.
//
// Do NOT release activeSurface — SurfaceView owns it via the holder.
activeSurface = null
} }
/** override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
* Read the actual SurfaceView width/height and push them to mpv. // Called every frame — no action needed, mpv drives rendering directly
* The PiP transition can fire surfaceCreated before the view's layout
* has settled to PiP dimensions, so we re-sync after layout passes.
*/
private fun syncSurfaceSizeToView() {
if (!surfaceReady) return
val w = surfaceView.width
val h = surfaceView.height
if (w > 0 && h > 0) {
renderer?.updateSurfaceSize(w, h)
}
} }
// MARK: - Video Loading // MARK: - Video Loading
@@ -315,7 +275,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Reset view-level state so a subsequent loadVideo() on the SAME view // Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live // instance re-creates the mpv handle and re-attaches the still-live
// SurfaceView surface. Without this, rendererStarted stays true and // TextureView surface. Without this, rendererStarted stays true and
// ensureRendererStarted() early-returns, so renderer.start() is never // ensureRendererStarted() early-returns, so renderer.start() is never
// called again — but stop() already nulled the renderer's mpv handle. // called again — but stop() already nulled the renderer's mpv handle.
// The next loadVideo() then runs loadVideoInternal() -> renderer.load() // The next loadVideo() then runs loadVideoInternal() -> renderer.load()
@@ -326,12 +286,13 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// which call destroy() immediately before router.replace() to the // which call destroy() immediately before router.replace() to the
// same route — Expo Router reuses the same MpvPlayerView instance, // same route — Expo Router reuses the same MpvPlayerView instance,
// so the next source load happens on this view without a remount. // so the next source load happens on this view without a remount.
//
// SurfaceView note: the surface is owned by the holder and survives
// across destroy()/loadVideo() on the same view instance. The next
// ensureRendererStarted() reads it from surfaceView.holder.surface.
rendererStarted = false rendererStarted = false
currentUrl = null currentUrl = null
// Move the active surface back to pending so ensureRendererStarted()
// re-attaches it to the freshly created mpv instance on next load.
// The Surface itself is still valid — onSurfaceTextureDestroyed has
// not fired because the TextureView is not being unmounted.
activeSurface?.let { pendingSurface = it }
activeSurface = null activeSurface = null
} }
@@ -366,10 +327,59 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture // MARK: - Picture in Picture
fun startPictureInPicture() { fun startPictureInPicture() {
isWaitingForPiPTransition = true
pipController?.startPictureInPicture() pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
} }
fun stopPictureInPicture() { fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
} }
@@ -537,12 +547,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
* off the JS path. * off the JS path.
*/ */
fun cleanup() { fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null) pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture() pipController?.stopPictureInPicture()
renderer?.stop() renderer?.stop()
renderer?.delegate = null renderer?.delegate = null
// SurfaceView owns the Surface via its holder — do NOT release it. // Release the Surface that wraps the SurfaceTexture. These Surface
// objects are created in onSurfaceTextureAvailable and were never
// released; each playback session previously leaked one. The
// SurfaceTexture itself is owned by TextureView and released by it
// via onSurfaceTextureDestroyed, so we leave it alone.
pendingSurface?.release()
pendingSurface = null
activeSurface?.release()
activeSurface = null activeSurface = null
surfaceReady = false surfaceReady = false
currentUrl = null currentUrl = null

View File

@@ -44,11 +44,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
private var currentPosition: Double = 0.0 private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0 private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0 private var playbackRate: Double = 1.0
// Independently tracks whether the system should auto-enter PiP on home
// press. Decoupled from playbackRate so that disabling auto-enter
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
// state that buildPiPActions() derives from playbackRate.
private var autoEnterEnabled: Boolean = false
private var videoWidth: Int = 0 private var videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
@@ -111,37 +106,15 @@ class PiPController(private val context: Context, private val appContext: AppCon
} }
fun stopPictureInPicture() { fun stopPictureInPicture() {
// Disable auto-enter eligibility without touching playbackRate.
// playbackRate drives the play/pause icon in buildPiPActions();
// mutating it here would cause a stale icon if PiP is re-entered
// before the next playback state callback corrects it.
autoEnterEnabled = false
isInPiPMode = false isInPiPMode = false
pipEntryNotified = false pipEntryNotified = false
unregisterLifecycleCallbacks() unregisterLifecycleCallbacks()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity() ?: return val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
// Push minimal params with just auto-enter disabled. Do NOT call activity.moveTaskToBack(false)
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
// setActions(), which would re-register the broadcast receiver
// (just unregistered above) and attach play/pause/skip actions to
// params being torn down. That leaves a live receiver + stale
// actions after the player has unmounted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
} }
} }
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
} }
fun isCurrentlyInPiP(): Boolean = isInPiPMode fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -153,7 +126,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) { fun setPlaybackRate(rate: Double) {
playbackRate = rate playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) { if (rate > 0) {
registerLifecycleCallbacks() registerLifecycleCallbacks()
@@ -236,7 +208,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions()) builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled) builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
} }
return builder.build() return builder.build()

View File

@@ -17,13 +17,13 @@
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production", "ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production", "ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
"prepare": "husky", "prepare": "husky",
"typecheck": "node scripts/typecheck.js", "typecheck": "bun scripts/typecheck.ts",
"check": "biome check . --max-diagnostics 1000", "check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .", "format": "biome format --write .",
"doctor": "expo-doctor", "doctor": "expo-doctor",
"i18n:check": "bun scripts/check-i18n-keys.mjs", "i18n:check": "bun scripts/check-i18n-keys.ts",
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused", "i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor", "test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
"postinstall": "patch-package" "postinstall": "patch-package"
}, },

View File

@@ -18,11 +18,11 @@
* - Edge cases the static scan cannot see can be allow-listed in the config file. * - Edge cases the static scan cannot see can be allow-listed in the config file.
* *
* Usage: * Usage:
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused * bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning * bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely * bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
* bun scripts/check-i18n-keys.mjs --json # machine-readable output * bun scripts/check-i18n-keys.ts --json # machine-readable output
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest) * bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
*/ */
import { import {
@@ -34,9 +34,20 @@ import {
} from "node:fs"; } from "node:fs";
import { extname, join, relative } from "node:path"; import { extname, join, relative } from "node:path";
type LocaleTree = { [key: string]: LocaleTree | string };
interface I18nConfig {
localesDir: string;
sourceLocale: string;
srcDirs: string[];
srcExtensions: string[];
excludeDirs: string[];
ignoreUnused: string[];
}
const ROOT = process.cwd(); const ROOT = process.cwd();
const args = process.argv.slice(2); const args = process.argv.slice(2);
const flag = (name, def) => { const flag = (name: string, def: string | boolean): string | boolean => {
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
if (!a) return def; if (!a) return def;
const [, v] = a.split("="); const [, v] = a.split("=");
@@ -48,7 +59,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
// ---- config ---- // ---- config ----
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json"); const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
const DEFAULT_CONFIG = { const DEFAULT_CONFIG: I18nConfig = {
localesDir: "translations", localesDir: "translations",
sourceLocale: "en", sourceLocale: "en",
// Scan the whole repo by default so keys referenced outside the obvious dirs // Scan the whole repo by default so keys referenced outside the obvious dirs
@@ -69,29 +80,36 @@ const DEFAULT_CONFIG = {
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally. // Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
ignoreUnused: [], ignoreUnused: [],
}; };
const config = existsSync(CONFIG_PATH) const config: I18nConfig = existsSync(CONFIG_PATH)
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) } ? {
...DEFAULT_CONFIG,
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
}
: DEFAULT_CONFIG; : DEFAULT_CONFIG;
// ---- helpers ---- // ---- helpers ----
const flatten = (obj, prefix = "", out = {}) => { const flatten = (
obj: LocaleTree,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k; const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out); if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
else out[key] = v; else out[key] = v as string;
} }
return out; return out;
}; };
const globMatch = (key, pattern) => { const globMatch = (key: string, pattern: string): boolean => {
if (pattern.endsWith(".*")) if (pattern.endsWith(".*"))
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1)); return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1)); if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
return key === pattern; return key === pattern;
}; };
const walk = (dir, files = []) => { const walk = (dir: string, files: string[] = []): string[] => {
let entries; let entries: string[];
try { try {
entries = readdirSync(dir); entries = readdirSync(dir);
} catch { } catch {
@@ -99,7 +117,7 @@ const walk = (dir, files = []) => {
} }
for (const name of entries) { for (const name of entries) {
const full = join(dir, name); const full = join(dir, name);
let st; let st: ReturnType<typeof statSync>;
try { try {
st = statSync(full); st = statSync(full);
} catch { } catch {
@@ -118,7 +136,7 @@ const walk = (dir, files = []) => {
// ---- load source keys ---- // ---- load source keys ----
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`); const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
const sourceKeys = Object.keys( const sourceKeys = Object.keys(
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))), flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
); );
const sourceKeySet = new Set(sourceKeys); const sourceKeySet = new Set(sourceKeys);
@@ -129,16 +147,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b"> const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
const dynamicPrefixes = new Set(); const dynamicPrefixes = new Set<string>();
const fullyDynamic = []; // { file, line } const fullyDynamic: Array<{ file: string; line: number }> = [];
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as // Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers); // usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps // line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
// `://` inside string URLs intact. // `://` inside string URLs intact.
const stripComments = (src) => const stripComments = (src: string): string =>
src src
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p); .replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
@@ -168,11 +186,11 @@ const prefixList = [...dynamicPrefixes];
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in // the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or // arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c). // explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
const literalUsed = (key) => const literalUsed = (key: string): boolean =>
codeBlob.includes(`"${key}"`) || codeBlob.includes(`"${key}"`) ||
codeBlob.includes(`'${key}'`) || codeBlob.includes(`'${key}'`) ||
codeBlob.includes(`\`${key}\``); codeBlob.includes(`\`${key}\``);
const isUsed = (key) => const isUsed = (key: string): boolean =>
literalUsed(key) || literalUsed(key) ||
prefixList.some((p) => key.startsWith(p)) || prefixList.some((p) => key.startsWith(p)) ||
config.ignoreUnused.some((g) => globMatch(key, g)); config.ignoreUnused.some((g) => globMatch(key, g));
@@ -191,25 +209,22 @@ const missing = [...usedStatic]
// keys are static literals in practice; revisit if dynamic key constants become common. // keys are static literals in practice; revisit if dynamic key constants become common.
// ---- optional fix: strip dead keys from the source locale (en.json) ---- // ---- optional fix: strip dead keys from the source locale (en.json) ----
const removeKey = (obj, parts) => { const removeKey = (obj: LocaleTree, parts: string[]): void => {
const [head, ...rest] = parts; const [head, ...rest] = parts;
if (!(head in obj)) return; if (!(head in obj)) return;
if (rest.length === 0) { if (rest.length === 0) {
delete obj[head]; delete obj[head];
return; return;
} }
removeKey(obj[head], rest); const child = obj[head];
if ( if (!child || typeof child !== "object") return;
obj[head] && removeKey(child, rest);
typeof obj[head] === "object" && if (Object.keys(child).length === 0) delete obj[head];
Object.keys(obj[head]).length === 0
)
delete obj[head];
}; };
if (FIX_UNUSED && unused.length) { if (FIX_UNUSED && unused.length) {
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes // Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
// the keys from them automatically on the next sync once they disappear from the source. // the keys from them automatically on the next sync once they disappear from the source.
const data = JSON.parse(readFileSync(sourcePath, "utf8")); const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
for (const key of unused) removeKey(data, key.split(".")); for (const key of unused) removeKey(data, key.split("."));
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`); writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
console.log( console.log(
@@ -259,7 +274,7 @@ if (JSON_OUT) {
); );
for (const k of unused) console.log(` - ${k}`); for (const k of unused) console.log(` - ${k}`);
console.log( console.log(
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`, `\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
); );
console.log( console.log(
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`, ` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,

View File

@@ -21,8 +21,14 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
interface Issue {
number: number;
title: string;
body: string | null;
}
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured. // Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
const numEnv = (name, def) => { const numEnv = (name: string, def: number): number => {
const raw = process.env[name]; const raw = process.env[name];
if (raw === undefined || raw === "") return def; if (raw === undefined || raw === "") return def;
const n = Number(raw); const n = Number(raw);
@@ -51,9 +57,9 @@ const STOP = new Set(
).split(/\s+/), ).split(/\s+/),
); );
const stem = (w) => w.replace(/(ing|ed|es|s)$/, ""); const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
const tokens = (s) => const tokens = (s: string | null): string[] =>
(s || "") (s || "")
.toLowerCase() .toLowerCase()
.replace(/```[\s\S]*?```/g, " ") // drop code blocks .replace(/```[\s\S]*?```/g, " ") // drop code blocks
@@ -65,7 +71,7 @@ const tokens = (s) =>
.map(stem) .map(stem)
.filter((w) => w.length > 2); .filter((w) => w.length > 2);
const jaccard = (a, b) => { const jaccard = (a: string[], b: string[]): number => {
const A = new Set(a); const A = new Set(a);
const B = new Set(b); const B = new Set(b);
if (!A.size || !B.size) return 0; if (!A.size || !B.size) return 0;
@@ -76,14 +82,14 @@ const jaccard = (a, b) => {
const newTitle = tokens(TITLE); const newTitle = tokens(TITLE);
const newBody = tokens(BODY); const newBody = tokens(BODY);
const score = (o) => const score = (o: Issue): number =>
0.6 * jaccard(newTitle, tokens(o.title)) + 0.6 * jaccard(newTitle, tokens(o.title)) +
0.4 * jaccard(newBody, tokens(o.body)); 0.4 * jaccard(newBody, tokens(o.body));
// fetch open issues (excluding PRs and the new issue itself) // fetch open issues (excluding PRs and the new issue itself)
let issues; let issues: Issue[];
if (process.env.DUP_FIXTURE) { if (process.env.DUP_FIXTURE) {
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")); issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
} else { } else {
const raw = execFileSync( const raw = execFileSync(
"gh", "gh",
@@ -105,7 +111,7 @@ if (process.env.DUP_FIXTURE) {
issues = raw issues = raw
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
.map((l) => JSON.parse(l)); .map((l) => JSON.parse(l) as Issue);
} }
const matches = issues const matches = issues
@@ -123,7 +129,7 @@ if (!matches.length) {
// Neutralise other issues' titles before echoing them back: break @mentions and // Neutralise other issues' titles before echoing them back: break @mentions and
// strip markdown/HTML control chars so a maliciously-named issue can't ping people // strip markdown/HTML control chars so a maliciously-named issue can't ping people
// or inject formatting into our comment. GitHub linkifies "#123" on its own. // or inject formatting into our comment. GitHub linkifies "#123" on its own.
const safeTitle = (t) => const safeTitle = (t: string): string =>
(t || "") (t || "")
.replace(/@/g, "@") .replace(/@/g, "@")
.replace(/[`<>|*_~[\]]/g, " ") .replace(/[`<>|*_~[\]]/g, " ")

View File

@@ -1,62 +0,0 @@
#!/usr/bin/env node
const _fs = require("node:fs");
const path = require("node:path");
const process = require("node:process");
const { execSync } = require("node:child_process");
const root = process.cwd();
// const tvosPath = path.join(root, 'iostv');
// const iosPath = path.join(root, 'iosmobile');
// const androidPath = path.join(root, 'androidmobile');
// const androidTVPath = path.join(root, 'androidtv');
// const device = process.argv[2];
// const platform = process.argv[2];
const isTV = process.env.EXPO_TV || false;
const paths = new Map([
["tvos", path.join(root, "iostv")],
["ios", path.join(root, "iosmobile")],
["android", path.join(root, "androidmobile")],
["androidtv", path.join(root, "androidtv")],
]);
// const platformPath = paths.get(platform);
if (isTV) {
stdout = execSync(
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
"androidtv",
)} android`,
);
console.log(stdout.toString());
} else {
stdout = execSync(
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
);
console.log(stdout.toString());
stdout = execSync(
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
);
console.log(stdout.toString());
}
// target = "";
// switch (platform) {
// case "tvos":
// target = "ios";
// break;
// case "ios":
// target = "ios";
// break;
// case "android":
// target = "android";
// break;
// case "androidtv":
// target = "android";
// break;
// }

View File

@@ -1,5 +1,8 @@
const { execFileSync } = require("node:child_process"); import { execFileSync } from "node:child_process";
const process = require("node:process"); import { createRequire } from "node:module";
import process from "node:process";
const require = createRequire(import.meta.url);
// Enhanced ANSI color codes and styles // Enhanced ANSI color codes and styles
const colors = { const colors = {
@@ -32,7 +35,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR; const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message, color = "") { function log(message: string, color = "") {
if (useColor && color) { if (useColor && color) {
console.log(`${color}${message}${colors.reset}`); console.log(`${color}${message}${colors.reset}`);
} else { } else {
@@ -40,7 +43,7 @@ function log(message, color = "") {
} }
} }
function formatError(errorLine) { function formatError(errorLine: string): string {
if (!useColor) return errorLine; if (!useColor) return errorLine;
// Color file paths in cyan // Color file paths in cyan
@@ -70,12 +73,15 @@ function formatError(errorLine) {
return formatted; return formatted;
} }
function parseErrorsAndCreateSummary(errorOutput) { function parseErrorsAndCreateSummary(errorOutput: string): {
formattedErrors: string[];
errorsByFile: Map<string, number>;
} {
const lines = errorOutput.split("\n").filter((line) => line.trim()); const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map(); const errorsByFile = new Map<string, number>();
const formattedErrors = []; const formattedErrors: string[] = [];
let currentError = []; let currentError: string[] = [];
for (const line of lines) { for (const line of lines) {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
@@ -96,7 +102,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
if (!errorsByFile.has(filePath)) { if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0); errorsByFile.set(filePath, 0);
} }
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1); errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
// Start new error // Start new error
currentError.push(formatError(line)); currentError.push(formatError(line));
@@ -119,7 +125,7 @@ function parseErrorsAndCreateSummary(errorOutput) {
return { formattedErrors, errorsByFile }; return { formattedErrors, errorsByFile };
} }
function createErrorSummaryTable(errorsByFile) { function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
if (errorsByFile.size === 0) return ""; if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort( const sortedFiles = Array.from(errorsByFile.entries()).sort(
@@ -136,7 +142,7 @@ function createErrorSummaryTable(errorsByFile) {
return table; return table;
} }
function runTypeCheck() { function runTypeCheck(): { ok: boolean } {
const extraArgs = process.argv.slice(2); const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available // Prefer local TypeScript binary when available
@@ -150,16 +156,13 @@ function runTypeCheck() {
"false", "false",
...extraArgs, ...extraArgs,
]; ];
let execArgs = null; let execArgs: { cmd: string; args: string[] };
try { try {
const tscBin = require.resolve("typescript/bin/tsc"); const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] }; execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch { } catch {
// fallback to PATH tsc // fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
execArgs = { execArgs = { cmd: "tsc", args: runnerArgs };
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
} }
try { try {
@@ -183,7 +186,21 @@ function runTypeCheck() {
); );
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || ""; const execError = error as { stderr?: string; stdout?: string };
const errorOutput = [execError.stdout, execError.stderr]
.filter((chunk): chunk is string => Boolean(chunk))
.join("\n");
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
// launch failure fall through to the "passed" branch and green-light CI.
if (!errorOutput) {
const message = error instanceof Error ? error.message : String(error);
log(
`${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
colors.red,
);
return { ok: false };
}
// Filter out jellyseerr utils errors - this is a third-party git submodule // Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors // that generates a large volume of known type errors

View File

@@ -484,7 +484,6 @@
}, },
"common": { "common": {
"no_results": "No results", "no_results": "No results",
"select": "Select",
"no_trailer_available": "No trailer available", "no_trailer_available": "No trailer available",
"video": "Video", "video": "Video",
"audio": "Audio", "audio": "Audio",

View File

@@ -3,17 +3,17 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import { generateDeviceProfile } from "./native"; import type {
DeviceProfile,
/** SubtitleProfile,
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType } from "@jellyfin/sdk/lib/generated-client/models";
*/ import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
/** /**
* Download-specific subtitle profiles. * Download-specific subtitle profiles.
* These are more permissive than streaming profiles since we can embed subtitles. * These are more permissive than streaming profiles since we can embed subtitles.
*/ */
const downloadSubtitleProfiles = [ const downloadSubtitleProfiles: SubtitleProfile[] = [
// Official formats // Official formats
{ Format: "vtt", Method: "Encode" }, { Format: "vtt", Method: "Encode" },
{ Format: "webvtt", Method: "Encode" }, { Format: "webvtt", Method: "Encode" },
@@ -46,11 +46,10 @@ const downloadSubtitleProfiles = [
/** /**
* Generates a device profile optimized for downloads. * Generates a device profile optimized for downloads.
* Uses the same audio codec logic as streaming but with download-specific bitrate limits. * Uses the same audio codec logic as streaming but with download-specific bitrate limits.
*
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
* @returns {Object} Jellyfin device profile for downloads
*/ */
export const generateDownloadProfile = (audioMode = "auto") => { export const generateDownloadProfile = (
audioMode: AudioTranscodeModeType = "auto",
): DeviceProfile => {
// Get the base profile with proper audio codec configuration // Get the base profile with proper audio codec configuration
const baseProfile = generateDeviceProfile({ audioMode }); const baseProfile = generateDeviceProfile({ audioMode });

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native"; import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
import { getSubtitleProfiles } from "./subtitles"; import { getSubtitleProfiles } from "./subtitles";
@@ -193,7 +194,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
}, },
], ],
SubtitleProfiles: getSubtitleProfiles(), SubtitleProfiles: getSubtitleProfiles(),
}; } satisfies DeviceProfile;
return profile; return profile;
}; };

View File

@@ -3,6 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
// Image-based formats - these need to be burned in by Jellyfin (Encode method) // Image-based formats - these need to be burned in by Jellyfin (Encode method)
// because MPV cannot load them externally over HTTP // because MPV cannot load them externally over HTTP
@@ -13,7 +14,7 @@ const IMAGE_BASED_FORMATS = [
"pgssub", "pgssub",
"teletext", "teletext",
"vobsub", "vobsub",
]; ] as const;
// Text-based formats - these can be loaded externally by MPV // Text-based formats - these can be loaded externally by MPV
const TEXT_BASED_FORMATS = [ const TEXT_BASED_FORMATS = [
@@ -37,10 +38,10 @@ const TEXT_BASED_FORMATS = [
"text", "text",
"vplayer", "vplayer",
"xsub", "xsub",
]; ] as const;
export const getSubtitleProfiles = () => { export const getSubtitleProfiles = (): SubtitleProfile[] => {
const profiles = []; const profiles: SubtitleProfile[] = [];
// Image-based formats: Embed or Encode (burn-in), NOT External // Image-based formats: Embed or Encode (burn-in), NOT External
for (const format of IMAGE_BASED_FORMATS) { for (const format of IMAGE_BASED_FORMATS) {
@@ -58,4 +59,4 @@ export const getSubtitleProfiles = () => {
}; };
// Export for use in player filtering // Export for use in player filtering
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS; export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;

View File

@@ -1,19 +0,0 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
export type PlatformType = "ios" | "android";
export interface TrackPlayerProfileOptions {
/** Target platform */
platform?: PlatformType;
}
export function generateTrackPlayerProfile(
options?: TrackPlayerProfileOptions,
): any;
declare const _default: any;
export default _default;

View File

@@ -3,23 +3,25 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
import type {
CodecProfile,
DeviceProfile,
DirectPlayProfile,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Platform } from "react-native"; import { Platform } from "react-native";
import MediaTypes from "../../constants/MediaTypes"; import MediaTypes from "../../constants/MediaTypes";
import type { PlatformType } from "./native";
/** export interface TrackPlayerProfileOptions {
* @typedef {"ios" | "android"} PlatformType /** Target platform */
* platform?: PlatformType;
* @typedef {Object} TrackPlayerProfileOptions }
* @property {PlatformType} [platform] - Target platform
*/
/** /**
* Audio direct play profiles for react-native-track-player. * Audio direct play profiles for react-native-track-player.
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support. * iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
*
* @param {PlatformType} platform
*/ */
const getDirectPlayProfile = (platform) => { const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
if (platform === "ios") { if (platform === "ios") {
// iOS AVPlayer supported formats // iOS AVPlayer supported formats
return { return {
@@ -39,10 +41,8 @@ const getDirectPlayProfile = (platform) => {
/** /**
* Audio codec profiles for react-native-track-player. * Audio codec profiles for react-native-track-player.
*
* @param {PlatformType} platform
*/ */
const getCodecProfile = (platform) => { const getCodecProfile = (platform: PlatformType): CodecProfile => {
if (platform === "ios") { if (platform === "ios") {
// iOS AVPlayer codec constraints // iOS AVPlayer codec constraints
return { return {
@@ -64,12 +64,11 @@ const getCodecProfile = (platform) => {
* This profile is specifically for standalone audio playback using: * This profile is specifically for standalone audio playback using:
* - AVPlayer on iOS * - AVPlayer on iOS
* - ExoPlayer on Android * - ExoPlayer on Android
*
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
* @returns {Object} Jellyfin device profile for track player
*/ */
export const generateTrackPlayerProfile = (options = {}) => { export const generateTrackPlayerProfile = (
const platform = options.platform || Platform.OS; options: TrackPlayerProfileOptions = {},
): DeviceProfile => {
const platform = (options.platform || Platform.OS) as PlatformType;
return { return {
Name: "Track Player", Name: "Track Player",