diff --git a/mobile/src/PreferencesContext.tsx b/mobile/src/PreferencesContext.tsx index 4d7467b..efa9a84 100644 --- a/mobile/src/PreferencesContext.tsx +++ b/mobile/src/PreferencesContext.tsx @@ -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(null); export function PreferencesProvider({ children }: { children: React.ReactNode }) { const [gridColumns, setCols] = useState(2); + const [defaultQuality, setQuality] = useState('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 {children}; + const setDefaultQuality = (q: DefaultQuality) => { + setQuality(q); + persistDefaultQuality(q).catch(() => {}); + }; + + return ( + + {children} + + ); } export function usePreferences(): Prefs { diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index e90e4be..6ca2bae 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -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', diff --git a/mobile/src/screens/AppLockSettingsScreen.tsx b/mobile/src/screens/AppLockSettingsScreen.tsx index 7bf9eec..43cd561 100644 --- a/mobile/src/screens/AppLockSettingsScreen.tsx +++ b/mobile/src/screens/AppLockSettingsScreen.tsx @@ -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('menu'); const [newPin, setNewPin] = useState(''); const [errorText, setErrorText] = useState(null); - const { gridColumns, setGridColumns } = usePreferences(); + const { gridColumns, setGridColumns, defaultQuality, setDefaultQuality } = usePreferences(); const navigation = useNavigation>(); async function refresh() { @@ -297,6 +307,30 @@ export function AppLockSettingsScreen() { + + Playback + + Default quality — auto-picked when available so you skip the chooser. "Ask" shows + the quality menu every time (movie parts always ask). + + + {QUALITY_OPTIONS.map((opt) => { + const active = defaultQuality === opt.value; + return ( + setDefaultQuality(opt.value)} + > + + {opt.label} + + + ); + })} + + + About diff --git a/mobile/src/screens/PlaybackQualityModal.tsx b/mobile/src/screens/PlaybackQualityModal.tsx index 4a07f93..b90eabd 100644 --- a/mobile/src/screens/PlaybackQualityModal.tsx +++ b/mobile/src/screens/PlaybackQualityModal.tsx @@ -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, diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index ee74d08..f99b311 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -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>(); + const { defaultQuality } = usePreferences(); const [resolving, setResolving] = React.useState(false); const [qualityLinks, setQualityLinks] = React.useState(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); diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts index 64ec9e4..b3adc9c 100644 --- a/mobile/src/storage.ts +++ b/mobile/src/storage.ts @@ -86,6 +86,24 @@ export async function setGridColumns(n: number): Promise { 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 { + 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 { + await SecureStore.setItemAsync(DEFAULT_QUALITY_KEY, String(v)); +} + export interface Credentials { baseUrl: string; apiKey: string;