Compare commits

..

1 Commits

Author SHA1 Message Date
Gauvino
3c8369ea4d ci(issue-form): auto-populate version dropdown from GitHub releases
Replaces the broken update workflow (it targeted a non-existent bug_report.yml
via the npm-based populate action; Streamyfin isn't on npm) with
scripts/update-issue-form.mjs (Bun, dep-free): reads release tags via gh and
rewrites the 'version' dropdown in issue_report.yml, preserving sentinels
(older, TestFlight/Development build), newest-first, capped at
ISSUE_FORM_LIMIT (5) — so each new release rotates the oldest entry out.

Drafts/prereleases are included on purpose: release.yml drafts
v<app.json version> for every TestFlight (iOS) / beta (Android) build, and the
app shows that nativeApplicationVersion to users (UserInfo.tsx), so those
in-flight versions must be selectable. Runs on release events + a weekly cron
safety net + workflow_dispatch, opens a PR and enables auto-merge. Seeds the
current versions.
2026-06-01 16:53:53 +02:00
18 changed files with 283 additions and 152 deletions

View File

@@ -77,10 +77,12 @@ body:
label: Streamyfin Version
description: What version of Streamyfin are you running?
options:
- 0.54.1 (TestFlight)
- 0.51.0
- 0.47.1
- 0.30.2
- 0.28.0
- older
- TestFlight/Development build
validations:
required: true

View File

@@ -1,67 +1,91 @@
name: 🐛 Update Bug Report Template
name: 🐛 Update Issue Form Versions
on:
release:
types: [published] # Run on every published release on any branch
# Also fire on drafts/prereleases so versions that aren't a full release yet
# (TestFlight / dev builds) still land in the dropdown.
types: [published, released, prereleased, created, deleted]
schedule:
- cron: "0 3 * * 1" # Weekly safety net (Mondays 03:00 UTC) in case a release event was missed
workflow_dispatch:
concurrency:
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
cancel-in-progress: true
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
update-bug-report:
update-issue-form:
name: 🔢 Populate version dropdown
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
issues: write
runs-on: ubuntu-24.04
steps:
- name: 📥 Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
node-version: '24.x'
cache: 'npm'
bun-version: latest
- name: 🔍 Extract minor version from app.json
id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main
with:
result-encoding: string
script: |
const fs = require('fs-extra');
const semver = require('semver');
const content = fs.readJsonSync('./app.json');
const version = content.expo.version;
const minorVersion = semver.minor(version);
return minorVersion.toString();
- name: 🔢 Populate version dropdown from GitHub releases
id: populate
run: bun scripts/update-issue-form.mjs
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- name: 📝 Update bug report version
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
semver: '^0.${{ steps.minor.outputs.result }}.0'
dry_run: no-push
- name: ⚙️ Update bug report node version dropdown
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
with:
dropdown: _node_version
package: node
semver: '>=24.0.0'
dry_run: no-push
- name: 📬 Commit and create pull request
- name: 📬 Create pull request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report
add-paths: .github/ISSUE_TEMPLATE/issue_report.yml
branch: ci/update-issue-form
base: develop
delete-branch: true
labels: ⚙️ ci, 🤖 github-actions
title: 'chore(): Update bug report template to match release version'
commit-message: "chore: update issue form version dropdown"
title: "chore: update issue form version dropdown"
# Follows .github/pull_request_template.md so the bot PR isn't flagged by PR validation.
body: |
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
# 📦 Pull Request
## 📝 Description
Automated update of the **Streamyfin Version** dropdown in `.github/ISSUE_TEMPLATE/issue_report.yml`, populated from the latest GitHub releases by `scripts/update-issue-form.mjs` (draft releases shown as `X (TestFlight)`).
**Version dropdown now lists:** ${{ steps.populate.outputs.versions }}
Triggered by `${{ github.event_name }}`${{ github.event.release.tag_name && format(' — release {0}', github.event.release.tag_name) || '' }} · [run ${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}).
## 🏷️ Ticket / Issue
N/A — automated maintenance.
### 🖼️ Screenshots / GIFs (if UI)
N/A — issue-template metadata only, no app UI.
## ✅ Checklist
- [x] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [x] Verified that changes behave as expected for all platforms
- [x] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [x] No secrets, hardcoded credentials, or private config files are included
- [x] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## 🔍 Testing Instructions
N/A — generated by CI from published releases; review the dropdown diff in `issue_report.yml`.
- name: 🔀 Enable auto-merge
if: steps.cpr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" \
|| echo "::warning::Could not enable auto-merge — enable 'Allow auto-merge' in repo settings (and branch protection); merge the PR manually for now."

View File

@@ -166,7 +166,7 @@ export default function IndexLayout() {
open={dropdownOpen}
onOpenChange={setDropdownOpen}
trigger={
<View>
<View className='pl-1.5'>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}

View File

@@ -11,7 +11,6 @@ import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import {
type ChapterEntry,
chapterStartsMs,
@@ -39,7 +38,6 @@ function ChapterListComponent({
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const safeArea = useControlsSafeAreaInsets();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
@@ -81,17 +79,7 @@ function ChapterListComponent({
supportedOrientations={["portrait", "landscape"]}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable
onPress={(e) => e.stopPropagation()}
style={[
styles.sheet,
{
marginLeft: safeArea.left,
marginRight: safeArea.right,
paddingBottom: safeArea.bottom,
},
]}
>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
@@ -172,12 +160,14 @@ const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
flexDirection: "row",

View File

@@ -133,6 +133,7 @@ const HomeMobile = () => {
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather

View File

@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
}) => {
const { t } = useTranslation();
const hasMovies = movieResults && movieResults.length > 0;
const hasTv = tvResults && tvResults.length > 0;
const hasPersons = personResults && personResults.length > 0;
if (loading) {
return null;
}
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
return (
<View>
{/* No section requests `hasTVPreferredFocus`: the native search field
keeps focus while typing, otherwise the first result would re-grab
focus on every keystroke as results re-render. The user navigates
down to the grid manually. */}
<TVJellyseerrMovieSection
title={t("search.request_movies")}
items={movieResults}
isFirstSection={false}
isFirstSection={hasMovies}
onItemPress={onMoviePress}
/>
<TVJellyseerrTvSection
title={t("search.request_series")}
items={tvResults}
isFirstSection={false}
isFirstSection={!hasMovies && hasTv}
onItemPress={onTvPress}
/>
<TVJellyseerrPersonSection
title={t("search.actors")}
items={personResults}
isFirstSection={false}
isFirstSection={!hasMovies && !hasTv && hasPersons}
onItemPress={onPersonPress}
/>
</View>

View File

@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
{/* No horizontal margin here: the native tvOS search bar centers itself
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
margins squeeze the bar's width and clip that trailing hint, so let
the native view span the full width and own its own insets. */}
<View
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section) => (
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
// Never auto-focus a result. The native search field owns focus
// while typing; `hasTVPreferredFocus` here would re-grab focus on
// every keystroke as results re-render. User navigates down to the
// grid manually.
isFirstSection={false}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={

View File

@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
// contentOffset only applies on initial mount; since this FlatList is
// reused across searches (stable key), a second search left the inset
// without the offset and the grid snapped flush to the left edge.
contentInset={{
left: edgePadding,
right: edgePadding,
}}
contentOffset={{ x: -edgePadding, y: 0 }}
contentContainerStyle={{
paddingHorizontal: edgePadding,
paddingVertical: SCALE_PADDING,
}}
/>

View File

@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
@@ -75,6 +75,9 @@ interface BottomControlsProps {
minutes: number;
seconds: number;
};
// Chapter props
chapterPositions?: number[];
}
export const BottomControls: FC<BottomControlsProps> = ({
@@ -108,10 +111,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
const { settings } = useSettings();
const { t } = useTranslation();
const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
@@ -142,9 +146,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
style={[
{
position: "absolute",
right: insets.right,
left: insets.left,
bottom: Math.max(insets.bottom - 17, 0),
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
bottom:
(settings?.safeAreaInControlsEnabled ?? true)
? Math.max(insets.bottom - 17, 0)
: 0,
},
]}
className={"flex flex-col px-2"}
@@ -180,6 +188,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null}
</View>
<View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
@@ -211,17 +230,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
onPress={handleNextEpisodeManual}
/>
)}
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center ml-4'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
</View>
</View>
<View

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
goToNextChapter,
}) => {
const { settings } = useSettings();
const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
return (
<View
style={{
position: "absolute",
top: "50%",
left: insets.left,
right: insets.right,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",

View File

@@ -219,6 +219,7 @@ export const Controls: FC<Props> = ({
hasNextChapter,
goToPreviousChapter,
goToNextChapter,
chapterPositions,
} = useChapterNavigation({
chapters: item.Chapters,
progress,
@@ -584,6 +585,7 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/>
</Animated.View>
</>

View File

@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at", { time: getFinishTime() })}
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
)}
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at", { time: getFinishTime() })}
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
)}

View File

@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -16,10 +17,10 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDownloadedEpisodesForSeason,
getDownloadedSeasonNumbers,
@@ -45,7 +46,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
scrollViewRef.current?.scrollToIndex(index, 100);
};
const isOffline = useOfflineMode();
const insets = useControlsSafeAreaInsets();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
// Set the initial season index
useEffect(() => {
@@ -180,9 +182,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
backgroundColor: "black",
height: "100%",
width: "100%",
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingTop:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
paddingLeft:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
paddingRight:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
}}
>
<View

View File

@@ -5,11 +5,12 @@ import type {
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useCallback, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import useRouter from "@/hooks/useAppRouter";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { OrientationLock } from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -57,8 +58,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
showTechnicalInfo = false,
onToggleTechnicalInfo,
}) => {
const { settings } = useSettings();
const router = useRouter();
const insets = useControlsSafeAreaInsets();
const insets = useSafeAreaInsets();
const lightHapticFeedback = useHaptic("light");
const { orientation, lockOrientation } = useOrientation();
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
@@ -97,9 +99,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
style={[
{
position: "absolute",
top: insets.top,
left: insets.left,
right: insets.right,
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
right:
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
padding: HEADER_LAYOUT.CONTAINER_PADDING,
},
]}

View File

@@ -16,8 +16,8 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentAudioIndex,
}) => {
const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const safeInsets = useControlsSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);
const opacity = useSharedValue(0);
@@ -268,8 +268,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
left: Math.max(insets.left, 48) + 20,
}
: {
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
top:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
left:
(settings?.safeAreaInControlsEnabled ?? true)
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
};
const textStyle = Platform.isTV

View File

@@ -1,18 +0,0 @@
import {
type EdgeInsets,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSettings } from "@/utils/atoms/settings";
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
/**
* Returns safe-area insets to apply to in-player controls, honoring the
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
* returns zero insets so controls can sit flush against the screen edges.
*/
export const useControlsSafeAreaInsets = (): EdgeInsets => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
};

View File

@@ -81,6 +81,7 @@ class MpvPlayerView: ExpoView {
private func setupView() {
clipsToBounds = true
backgroundColor = .black
configureAudioSession()
videoContainer = UIView()
videoContainer.translatesAutoresizingMaskIntoConstraints = false
@@ -140,26 +141,21 @@ class MpvPlayerView: ExpoView {
CATransaction.commit()
}
// MARK: - Audio Session & Notifications
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
let audioSession = AVAudioSession.sharedInstance()
do {
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
try session.setActive(true)
try audioSession.setCategory(
.playback,
mode: .moviePlayback,
policy: .longFormAudio,
options: []
)
try audioSession.setActive(true)
} catch {
print("Failed to configure audio session: \(error)")
}
}
/// Deactivate the session AND reset the category `setActive(false)` alone
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
/// reactivation (foreground, route change, other modules) re-steals audio.
private func tearDownAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(false, options: .notifyOthersOnDeactivation)
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
}
// MARK: - Audio Session & Notifications
private func setupNotifications() {
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
@@ -274,7 +270,6 @@ class MpvPlayerView: ExpoView {
func play() {
intendedPlayState = true
configureAudioSession()
setupRemoteCommands()
renderer?.play()
pipController?.setPlaybackRate(1.0)
@@ -445,7 +440,6 @@ class MpvPlayerView: ExpoView {
renderer?.stop()
displayLayer.removeFromSuperlayer()
clearNowPlayingInfo()
tearDownAudioSession()
NotificationCenter.default.removeObserver(self)
}
}
@@ -525,7 +519,9 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
}
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
// Audio output is now active - this is the right time to activate audio session and set Now Playing
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
nowPlayingManager.activateAudioSession()
syncNowPlaying(isPlaying: !isPaused())
}
}

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env bun
/**
* Populates the "Streamyfin Version" dropdown in the issue report form with the
* latest GitHub releases. Run by the "Update Issue Form Versions" workflow on
* release events + a weekly cron (and manually via workflow_dispatch).
*
* Source: GitHub releases, newest first, INCLUDING drafts and prereleases — those
* are the builds release.yml pushes to TestFlight (iOS) / beta (Android), and the
* app shows that same version to users. Draft releases are labelled "X (TestFlight)".
* Non-version sentinels (e.g. "older") are preserved at the end of the list.
*
* Usage:
* bun scripts/update-issue-form.mjs # rewrite the form in place
* ISSUE_FORM_LIMIT=8 bun scripts/update-issue-form.mjs
* bun scripts/update-issue-form.mjs --dry-run # print the new options, don't write
*
* Env: GITHUB_REPOSITORY (owner/repo), GH_TOKEN/GITHUB_TOKEN (for gh, provided in CI).
*/
import { execFileSync } from "node:child_process";
import {
appendFileSync,
readFileSync as read,
writeFileSync as write,
} from "node:fs";
const FORM = ".github/ISSUE_TEMPLATE/issue_report.yml";
const DROPDOWN_ID = "version"; // the `id:` of the dropdown to populate
const parsedLimit = Number.parseInt(process.env.ISSUE_FORM_LIMIT ?? "", 10);
const LIMIT =
Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 5;
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
const DRY = process.argv.includes("--dry-run");
// Matches "0.54.1" and prerelease/beta tags like "0.54.0-beta.1".
const isVersion = (s) => /^\d+\.\d+/.test(s.trim());
// 1. Fetch releases (newest first) with their draft flag. Drafts are the builds pushed
// to TestFlight (iOS) / beta (Android) by release.yml, so they aren't a full release
// yet — we label those "X (TestFlight)". (Listing drafts needs the token to have repo
// write access, which the workflow grants.)
const raw = execFileSync(
"gh",
[
"api",
`repos/${REPO}/releases`,
"--paginate",
"--jq",
".[] | [.tag_name, .draft] | @tsv",
],
{ encoding: "utf8" },
);
const seen = new Set();
const versions = [];
for (const line of raw.split("\n")) {
const [tag, draft] = line.split("\t");
if (!tag) continue;
const ver = tag.trim().replace(/^v/, "");
if (!isVersion(ver) || seen.has(ver)) continue;
seen.add(ver);
versions.push(draft === "true" ? `${ver} (TestFlight)` : ver);
if (versions.length >= LIMIT) break;
}
if (!versions.length) {
console.error("No release versions found — leaving the form untouched.");
process.exit(1);
}
// 2. rewrite the dropdown options, preserving non-version sentinels (e.g. "older").
// The old generic "TestFlight/Development build" entry is dropped — TestFlight
// versions are now shown individually as "X (TestFlight)".
const lines = read(FORM, "utf8").split("\n");
const idIdx = lines.findIndex((l) =>
l.match(new RegExp(`^\\s*id:\\s*${DROPDOWN_ID}\\s*$`)),
);
if (idIdx === -1)
throw new Error(`dropdown id: ${DROPDOWN_ID} not found in ${FORM}`);
const optIdx = lines.findIndex(
(l, i) => i > idIdx && /^\s*options:\s*$/.test(l),
);
if (optIdx === -1)
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
let end = optIdx + 1;
const sentinels = [];
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {
const val = lines[end].replace(/^\s*-\s+/, "");
if (!isVersion(val) && !/testflight/i.test(val)) sentinels.push(val);
end++;
}
const newOptions = [...versions, ...sentinels].map(
(v) => `${itemIndent}- ${v}`,
);
const updated = [
...lines.slice(0, optIdx + 1),
...newOptions,
...lines.slice(end),
].join("\n");
console.log(
`Versions: ${versions.join(", ")}${sentinels.length ? ` | kept: ${sentinels.join(", ")}` : ""}`,
);
if (DRY) {
console.log("--dry-run: not writing.");
} else {
write(FORM, updated);
console.log(`Updated ${FORM}.`);
}
// Expose the resulting list for the workflow (PR description).
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`versions=${versions.join(", ")}\n`,
);
}