feat(mobile): default playback quality — skip the quality chooser

Settings -> Playback lets the user pick a default (Ask / 4K / 1080p / 720p / 480p /
Lowest). When a resolved source has multiple qualities and one matches (largest height
<= target, else closest; 'lowest' picks the smallest), the app plays it directly instead
of popping the chooser. 'Ask' keeps the old behavior. pickByDefaultQuality lives next to
qualityToInt; wired into the three SceneDetail resolve sites. Movie parts are untouched
(separate partsPicker, never auto-collapsed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-01 10:37:20 +02:00
parent 7cf338f812
commit 129c71cf95
6 changed files with 136 additions and 11 deletions

View file

@ -1,26 +1,37 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { getGridColumns, setGridColumns as persistGridColumns } from './storage';
import {
DefaultQuality,
getDefaultQuality,
getGridColumns,
setDefaultQuality as persistDefaultQuality,
setGridColumns as persistGridColumns,
} from './storage';
/**
* Globalne preferencje UI (na razie tylko liczba kolumn siatki scen). Ładowane raz
* przy montażu z SecureStore (async) i dalej trzymane synchronicznie w state, żeby
* ekrany siatek mogły czytać wartość w renderze bez await. Default 2 do czasu załadowania.
* Globalne preferencje UI. Ładowane raz przy montażu z SecureStore (async) i dalej
* trzymane synchronicznie w state, żeby ekrany czytały wartość w renderze bez await.
*/
interface Prefs {
gridColumns: number;
setGridColumns: (n: number) => void;
defaultQuality: DefaultQuality;
setDefaultQuality: (q: DefaultQuality) => void;
}
const Ctx = createContext<Prefs | null>(null);
export function PreferencesProvider({ children }: { children: React.ReactNode }) {
const [gridColumns, setCols] = useState(2);
const [defaultQuality, setQuality] = useState<DefaultQuality>('auto');
useEffect(() => {
getGridColumns()
.then(setCols)
.catch(() => {});
getDefaultQuality()
.then(setQuality)
.catch(() => {});
}, []);
const setGridColumns = (n: number) => {
@ -28,7 +39,16 @@ export function PreferencesProvider({ children }: { children: React.ReactNode })
persistGridColumns(n).catch(() => {});
};
return <Ctx.Provider value={{ gridColumns, setGridColumns }}>{children}</Ctx.Provider>;
const setDefaultQuality = (q: DefaultQuality) => {
setQuality(q);
persistDefaultQuality(q).catch(() => {});
};
return (
<Ctx.Provider value={{ gridColumns, setGridColumns, defaultQuality, setDefaultQuality }}>
{children}
</Ctx.Provider>
);
}
export function usePreferences(): Prefs {

View file

@ -16,6 +16,16 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-01',
date: 'July 2026',
items: [
'Set a default video quality in Settings → Playback (e.g. 720p) to skip the quality chooser when that quality is available.',
'The "+N new" count on a favorite now matches what you actually see when you open it — no more "+6" with nothing new.',
'Smoother scrolling: when fresh scenes land at the top of a list, it no longer jumps under your finger.',
'Fixed: a video that plays fine no longer flashes "Playback failed / Mark broken" while it reconnects.',
],
},
{
id: '2026-06-29b',
date: 'June 2026',

View file

@ -27,6 +27,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { APP_VERSION } from '../lib/appVersion';
import { replayOnboarding } from '../lib/onboardingBus';
import { usePreferences } from '../PreferencesContext';
import type { DefaultQuality } from '../storage';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import { PinEntry } from './PinEntry';
@ -39,6 +40,15 @@ const TIMEOUT_OPTIONS: { label: string; seconds: number }[] = [
{ label: '15 min', seconds: 900 },
];
const QUALITY_OPTIONS: { label: string; value: DefaultQuality }[] = [
{ label: 'Ask', value: 'auto' },
{ label: '4K', value: 2160 },
{ label: '1080p', value: 1080 },
{ label: '720p', value: 720 },
{ label: '480p', value: 480 },
{ label: 'Lowest', value: 'lowest' },
];
type Stage = 'menu' | 'enter-current' | 'set-new' | 'confirm-new' | 'disable-confirm';
export function AppLockSettingsScreen() {
@ -47,7 +57,7 @@ export function AppLockSettingsScreen() {
const [stage, setStage] = useState<Stage>('menu');
const [newPin, setNewPin] = useState('');
const [errorText, setErrorText] = useState<string | null>(null);
const { gridColumns, setGridColumns } = usePreferences();
const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
async function refresh() {
@ -297,6 +307,30 @@ export function AppLockSettingsScreen() {
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Playback</Text>
<Text style={styles.hint}>
Default quality auto-picked when available so you skip the chooser. "Ask" shows
the quality menu every time (movie parts always ask).
</Text>
<View style={styles.chipRow}>
{QUALITY_OPTIONS.map((opt) => {
const active = defaultQuality === opt.value;
return (
<Pressable
key={String(opt.value)}
style={[styles.chip, active && styles.chipActive]}
onPress={() => setDefaultQuality(opt.value)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.row}>

View file

@ -10,6 +10,7 @@ import {
View,
} from 'react-native';
import { theme } from '../theme';
import type { DefaultQuality } from '../storage';
import type { StreamLink } from '../types';
export function qualityToInt(q: string | null | undefined): number {
@ -29,6 +30,35 @@ export function sortByQualityDesc(links: StreamLink[]): StreamLink[] {
return [...links].sort((a, b) => qualityToInt(b.quality) - qualityToInt(a.quality));
}
/**
* Wybierz link pasujący do domyślnej jakości usera; `null` pokaż picker (auto, brak
* dopasowania, albo same nieznane jakości). Reguła: największa wysokość <= target;
* gdy nic <= target (wszystkie wyższe) najbliższa. 'lowest' najmniejsza dodatnia.
* Linki o nieznanej wysokości (qualityToInt=0) pomijamy gdy jest jakikolwiek znany, żeby
* nieopisany link nie udawał wybranej jakości. NIE dotyczy movie parts (tam preserveOrder).
*/
export function pickByDefaultQuality(
links: StreamLink[],
pref: DefaultQuality,
): StreamLink | null {
if (pref === 'auto' || links.length === 0) return null;
const known = links
.map((l) => ({ l, px: qualityToInt(l.quality) }))
.filter((x) => x.px > 0);
if (known.length === 0) return null;
if (pref === 'lowest') {
return known.reduce((a, b) => (b.px < a.px ? b : a)).l;
}
const target = pref;
const atOrBelow = known.filter((x) => x.px <= target);
if (atOrBelow.length > 0) {
return atOrBelow.reduce((a, b) => (b.px > a.px ? b : a)).l; // największa <= target
}
return known.reduce((a, b) =>
Math.abs(b.px - target) < Math.abs(a.px - target) ? b : a,
).l; // wszystkie wyższe → najbliższa
}
export function PlaybackQualityModal({
visible,
links,

View file

@ -28,7 +28,8 @@ import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { PlaybackSource, SceneOut, StreamLink } from '../types';
import { PlaybackQualityModal } from './PlaybackQualityModal';
import { PlaybackQualityModal, pickByDefaultQuality } from './PlaybackQualityModal';
import { usePreferences } from '../PreferencesContext';
export function SceneDetailScreen() {
const client = useClient();
@ -479,8 +480,17 @@ function PlaybackButton({
const client = useClient();
const queryClient = useQueryClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
const { defaultQuality } = usePreferences();
const [resolving, setResolving] = React.useState(false);
const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null);
// Auto-quality: 1 link → graj; wiele → spróbuj dopasować domyślną jakość usera
// (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje
// per call-site (różni się: pornxp/phone woła przed pickerem, backend tylko gdy gra).
const pickAuto = (list: StreamLink[]): StreamLink | null => {
if (list.length === 1) return list[0];
return pickByDefaultQuality(list, defaultQuality);
};
// Tube origin format: 'tube:hqpornercom' (nowy) lub 'pornapp:hqpornercom' (legacy
// sprzed migracji 0011) — backend rozumie oba prefixy.
const isTube = source.origin.startsWith('tube:') || source.origin.startsWith('pornapp:');
@ -588,7 +598,8 @@ function PlaybackButton({
const links = await resolvePornxpPage(source.page_url);
if (links.length > 0) {
markStarted();
if (links.length === 1) await openAsVideo(links[0], source.page_url);
const pick = pickAuto(links);
if (pick) await openAsVideo(pick, source.page_url);
else setQualityLinks(links);
return;
}
@ -618,7 +629,8 @@ function PlaybackButton({
const links = await phoneResolver(source.page_url);
if (links.length > 0) {
markStarted();
if (links.length === 1) await openAsVideo(links[0], source.page_url);
const pick = pickAuto(links);
if (pick) await openAsVideo(pick, source.page_url);
else setQualityLinks(links);
return;
}
@ -688,9 +700,10 @@ function PlaybackButton({
// jest, niezależnie od kolejności w `links`.
const fallbackEmbedUrl = embedLinks[0]?.embed_url || res.best?.embed_url || undefined;
if (directLinks.length === 1) {
const autoPick = pickAuto(directLinks);
if (autoPick) {
markStarted();
await openAsVideo(directLinks[0], fallbackEmbedUrl);
await openAsVideo(autoPick, fallbackEmbedUrl);
return;
}
setQualityLinks(directLinks);

View file

@ -86,6 +86,24 @@ export async function setGridColumns(n: number): Promise<void> {
await SecureStore.setItemAsync(GRID_COLS_KEY, String(n));
}
// Domyślna jakość odtwarzania (user-request: mniej klików). Gdy resolve zwróci kilka
// jakości i jest dopasowanie do tej wartości, apka pomija picker i gra od razu. 'auto'
// = zawsze pokaż picker (stare zachowanie). 'lowest' = najniższa dostępna.
export type DefaultQuality = 'auto' | 2160 | 1080 | 720 | 480 | 'lowest';
const DEFAULT_QUALITY_KEY = 'goon.default_quality';
export async function getDefaultQuality(): Promise<DefaultQuality> {
const v = await SecureStore.getItemAsync(DEFAULT_QUALITY_KEY);
if (!v) return 'auto';
if (v === 'auto' || v === 'lowest') return v;
const n = parseInt(v, 10);
return n === 2160 || n === 1080 || n === 720 || n === 480 ? (n as DefaultQuality) : 'auto';
}
export async function setDefaultQuality(v: DefaultQuality): Promise<void> {
await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v));
}
export interface Credentials {
baseUrl: string;
apiKey: string;