mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-30 18:48:30 +01:00
Compare commits
10 Commits
I10n_crowd
...
chore/mpv-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e69a3b6c5 | ||
|
|
0bf8fac079 | ||
|
|
37b51abd34 | ||
|
|
6fe464088b | ||
|
|
769c7a2432 | ||
|
|
62c86533b1 | ||
|
|
4fc78f006d | ||
|
|
ab0957044f | ||
|
|
407ef3f51e | ||
|
|
0e531da2e0 |
95
.github/pull_request_template.md
vendored
95
.github/pull_request_template.md
vendored
@@ -1,91 +1,54 @@
|
||||
<!--
|
||||
Pull Request Template for Streamyfin
|
||||
====================================
|
||||
Use this template to help reviewers understand the purpose of your PR
|
||||
and to ensure all necessary checks are completed before merging.
|
||||
<!--
|
||||
Use a conventional commit title for the PR title,
|
||||
for example `feat(auth): add MFA`
|
||||
All sections below are required. Write N/A if a section is not applicable.
|
||||
If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
|
||||
-->
|
||||
|
||||
# 📦 Pull Request
|
||||
|
||||
## 🔖 Summary
|
||||
<!--
|
||||
🤖 AI ASSISTED?
|
||||
Uncomment the line below if AI was used to assist with this PR:
|
||||
-->
|
||||
<!--
|
||||
[](#) -->
|
||||
|
||||
## 📝 Description
|
||||
<!--
|
||||
A concise description of the changes introduced by this PR.
|
||||
Example:
|
||||
“Add real-time currency conversion widget to dashboard.”
|
||||
A short description of the changes and why you're making them.
|
||||
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
|
||||
-->
|
||||
|
||||
## 🏷️ Ticket / Issue
|
||||
<!--
|
||||
Link to the related ticket, issue or user story.
|
||||
You can also indicate if this PR supersedes a previous one.
|
||||
Example:
|
||||
- Closes #123
|
||||
- Fixes STREAMYFIN-456
|
||||
- Resolves #789
|
||||
- Supersedes #120
|
||||
- Related: #130
|
||||
Example: Fixes #123
|
||||
-->
|
||||
|
||||
## 🛠️ What’s Changed
|
||||
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
|
||||
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
|
||||
Spec: https://www.conventionalcommits.org/ -->
|
||||
|
||||
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
|
||||
- Scope (optional): e.g., auth, billing, mobile
|
||||
- Short summary: what changed and why (1–2 lines)
|
||||
-->
|
||||
|
||||
## 📋 Details
|
||||
<!--
|
||||
Provide more context or background. Explain any non-obvious decisions.
|
||||
Include screenshots or GIFs for UI changes if applicable.
|
||||
-->
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
||||
|
||||
### 🔐 Security & Privacy Impact
|
||||
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
|
||||
|
||||
### ⚡ Performance Impact
|
||||
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
||||
|
||||
### 🖼️ Screenshots / GIFs (if UI)
|
||||
<!-- Before/After, dark mode, responsive states. -->
|
||||
<!--
|
||||
Include screenshots of relevant UI changes for both Android and iOS.
|
||||
Before/After, responsive states (if relevant).
|
||||
-->
|
||||
|
||||
## ✅ Checklist
|
||||
<!--
|
||||
Review and check off items as you complete them.
|
||||
-->
|
||||
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
|
||||
- [ ] Type checks pass (tsc/biome/etc.)
|
||||
- [ ] Docs updated (README/ADR/usage/API)
|
||||
- [ ] No secrets/credentials included; env vars documented
|
||||
- [ ] Release notes/CHANGELOG entry added (if applicable)
|
||||
- [ ] Verified locally that changes behave as expected
|
||||
- [ ] Verified that changes behave as expected for all platforms
|
||||
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
||||
- [ ] No secrets, hardcoded credentials, or private config files are included
|
||||
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
||||
|
||||
## 🔍 Testing Instructions
|
||||
<!--
|
||||
Describe how reviewers can test your changes.
|
||||
Describe how reviewers can test your changes. This will help the PR get merged faster.
|
||||
Example:
|
||||
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
|
||||
2. Install deps: `npm|pnpm|yarn|bun install`
|
||||
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
|
||||
4. Run tests: `npm|pnpm|yarn|bun test`
|
||||
5. Verification steps:
|
||||
- [ ] Expected UI/endpoint behavior
|
||||
- [ ] Logs show no errors
|
||||
- [ ] Edge cases covered (list)
|
||||
1. Open the settings page and scroll to the bottom
|
||||
2. Verify that the clear data button is visible and pressable
|
||||
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
|
||||
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
|
||||
-->
|
||||
|
||||
## ⚙️ Deployment Notes
|
||||
<!--
|
||||
Describe any deployment considerations such as config, environment vars, or native builds.
|
||||
-->
|
||||
|
||||
## 📝 Additional Notes
|
||||
<!--
|
||||
Any other information or references related to this PR.
|
||||
-->
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -72,4 +72,6 @@ modules/background-downloader/android/build/*
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
build/
|
||||
.agents/skills/**
|
||||
skills-lock.json
|
||||
|
||||
4
app.json
4
app.json
@@ -124,8 +124,8 @@
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
{
|
||||
"podName": "MPVKit-GPL",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||
"podName": "MPVKit",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ import useRouter from "@/hooks/useAppRouter";
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -47,15 +48,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
title: t("home.downloads.downloads_title"),
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -66,15 +59,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -84,15 +69,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -102,15 +79,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -120,15 +89,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -138,15 +99,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -156,15 +109,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -174,15 +119,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -210,15 +147,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -228,15 +157,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -246,15 +167,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -264,15 +177,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -282,15 +187,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -300,15 +197,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -318,15 +207,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
@@ -336,11 +217,7 @@ export default function IndexLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
|
||||
2
bun.lock
2
bun.lock
@@ -1686,7 +1686,7 @@
|
||||
|
||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||
|
||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
|
||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||
|
||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
@@ -254,23 +254,39 @@ const PlatformDropdownComponent = ({
|
||||
// Otherwise render as individual buttons
|
||||
if (radioOptions.length > 0) {
|
||||
if (group.title) {
|
||||
// Use Picker for grouped options
|
||||
// Use a nested ContextMenu as a submenu for grouped options
|
||||
const selectedOption = radioOptions.find(
|
||||
(opt) => opt.selected,
|
||||
);
|
||||
const displayTitle = selectedOption
|
||||
? `${group.title}: ${selectedOption.label}`
|
||||
: group.title;
|
||||
|
||||
items.push(
|
||||
<Picker
|
||||
key={`picker-${groupIndex}`}
|
||||
label={group.title}
|
||||
options={radioOptions.map((opt) => opt.label)}
|
||||
variant='menu'
|
||||
selectedIndex={radioOptions.findIndex(
|
||||
(opt) => opt.selected,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
const selectedOption = radioOptions[index];
|
||||
selectedOption?.onPress();
|
||||
onOptionSelect?.(selectedOption?.value);
|
||||
}}
|
||||
/>,
|
||||
<ContextMenu key={`submenu-${groupIndex}`}>
|
||||
<ContextMenu.Trigger>
|
||||
<Button>{displayTitle}</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{radioOptions.map((option, optionIndex) => (
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
systemImage={
|
||||
option.selected
|
||||
? "checkmark.circle.fill"
|
||||
: "circle"
|
||||
}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>,
|
||||
);
|
||||
} else {
|
||||
// Render radio options as direct buttons
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
|
||||
import { Platform, View } from "react-native";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
@@ -49,32 +49,66 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
></Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
<Picker
|
||||
label={t("library.filters.sort_by")}
|
||||
options={sortOptions.map((item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||
)}
|
||||
variant='menu'
|
||||
selectedIndex={sortOptions.indexOf(
|
||||
jellyseerrOrderBy as unknown as string,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrOrderBy(
|
||||
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Picker
|
||||
label={t("library.filters.sort_order")}
|
||||
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||
variant='menu'
|
||||
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrSortOrder(orderOptions[index]);
|
||||
}}
|
||||
/>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Button>
|
||||
{`${t("library.filters.sort_by")}: ${t(
|
||||
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
|
||||
)}`}
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{sortOptions.map((item) => {
|
||||
const label = t(
|
||||
`home.settings.plugins.jellyseerr.order_by.${item}`,
|
||||
);
|
||||
const isSelected =
|
||||
jellyseerrOrderBy ===
|
||||
(item as unknown as JellyseerrSearchSort);
|
||||
return (
|
||||
<Button
|
||||
key={item}
|
||||
systemImage={
|
||||
isSelected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() =>
|
||||
setJellyseerrOrderBy(
|
||||
item as unknown as JellyseerrSearchSort,
|
||||
)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Button>
|
||||
{`${t("library.filters.sort_order")}: ${t(
|
||||
`library.filters.${jellyseerrSortOrder}`,
|
||||
)}`}
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{orderOptions.map((item) => {
|
||||
const label = t(`library.filters.${item}`);
|
||||
const isSelected = jellyseerrSortOrder === item;
|
||||
return (
|
||||
<Button
|
||||
key={item}
|
||||
systemImage={
|
||||
isSelected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() => setJellyseerrSortOrder(item)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
|
||||
@@ -177,6 +177,9 @@ export const useAddToWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
@@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
|
||||
@@ -220,20 +220,27 @@ final class MPVLayerRenderer {
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = nil
|
||||
|
||||
queue.sync { [weak self] in
|
||||
guard let self, let handle = self.mpv else { return }
|
||||
|
||||
mpv_set_wakeup_callback(handle, nil, nil)
|
||||
mpv_terminate_destroy(handle)
|
||||
if let handle = self.mpv {
|
||||
self.mpv = nil
|
||||
// Remove the wakeup callback synchronously while `self` is still
|
||||
// alive so it can never fire against a deallocated instance.
|
||||
mpv_set_wakeup_callback(handle, nil, nil)
|
||||
// Destroy mpv OFF the main thread. mpv_terminate_destroy() blocks
|
||||
// until all mpv threads (including the vo_avfoundation output thread)
|
||||
// are joined, and that teardown needs the main run loop to finish.
|
||||
// Calling it via queue.sync from deinit (main thread) deadlocks/freezes
|
||||
// the UI. queue.async only references `handle`, never `self`.
|
||||
queue.async {
|
||||
mpv_terminate_destroy(handle)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let layer = self.displayLayer
|
||||
DispatchQueue.main.async {
|
||||
if #available(iOS 18.0, *) {
|
||||
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
|
||||
layer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
|
||||
} else {
|
||||
self.displayLayer.flushAndRemoveImage()
|
||||
layer.flushAndRemoveImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPVKit for Expo'
|
||||
s.description = 'MPVKit for Expo'
|
||||
s.author = 'mpvkit'
|
||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit-GPL'
|
||||
s.dependency 'MPVKit'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'VALID_ARCHS' => 'arm64',
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
|
||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||
}
|
||||
|
||||
s.user_target_xcconfig = {
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
|
||||
@@ -150,6 +150,16 @@ final class PiPController: NSObject {
|
||||
CMTimebaseSetRate(tb, rate: Float64(rate))
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetRate(tb, rate: 0)
|
||||
}
|
||||
sampleBufferDisplayLayer?.controlTimebase = nil
|
||||
timebase = nil
|
||||
pipController?.delegate = nil
|
||||
pipController = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
@@ -341,6 +341,14 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
||||
loadPluginSettings(),
|
||||
);
|
||||
|
||||
const hasMeaningfulSettingValue = (value: unknown) =>
|
||||
value !== undefined && value !== null && value !== "";
|
||||
|
||||
const getEffectiveSettingValue = <K extends keyof Settings>(
|
||||
settings: Partial<Settings> | null | undefined,
|
||||
settingsKey: K,
|
||||
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
|
||||
|
||||
export const useSettings = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||
@@ -381,12 +389,13 @@ export const useSettings = () => {
|
||||
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
||||
if (setting && !setting.locked && setting.value !== undefined) {
|
||||
const settingsKey = key as keyof Settings;
|
||||
// Apply if forceOverride is true, or if user hasn't explicitly set this value
|
||||
if (
|
||||
forceOverride ||
|
||||
_settings[settingsKey] === undefined ||
|
||||
_settings[settingsKey] === ""
|
||||
) {
|
||||
const effectiveValue = getEffectiveSettingValue(
|
||||
_settings,
|
||||
settingsKey,
|
||||
);
|
||||
// Apply if forceOverride is true, or if neither persisted settings
|
||||
// nor app defaults provide a meaningful value.
|
||||
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
|
||||
(updates as any)[settingsKey] = setting.value;
|
||||
}
|
||||
}
|
||||
@@ -438,28 +447,22 @@ export const useSettings = () => {
|
||||
|
||||
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
||||
// If admin sets locked to false but provides a value,
|
||||
// use user settings first and fallback on admin setting if required.
|
||||
// use persisted settings first, then app defaults, and only fallback on the
|
||||
// plugin value when neither provides a meaningful value.
|
||||
const settings: Settings = useMemo(() => {
|
||||
const unlockedPluginDefaults: Partial<Settings> = {};
|
||||
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
||||
Partial<Settings>
|
||||
>((acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
const settingsKey = key as keyof Settings;
|
||||
|
||||
// Make sure we override default settings with plugin settings when they are not locked.
|
||||
if (
|
||||
!locked &&
|
||||
value !== undefined &&
|
||||
_settings?.[settingsKey] !== value
|
||||
) {
|
||||
(unlockedPluginDefaults as any)[settingsKey] = value;
|
||||
}
|
||||
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||
|
||||
(acc as any)[settingsKey] = locked
|
||||
? value
|
||||
: (_settings?.[settingsKey] ?? value);
|
||||
: hasMeaningfulSettingValue(effectiveValue)
|
||||
? effectiveValue
|
||||
: value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
Reference in New Issue
Block a user