feat(player): zmiana jakosci w trakcie odtwarzania
Some checks are pending
Backend tests / test (push) Waiting to run
Some checks are pending
Backend tests / test (push) Waiting to run
User-request 89845707 ('quality customisation button in player'). Do tej pory
jakosc dalo sie wybrac TYLKO przed startem (PlaybackQualityModal w SceneDetail);
zeby ja zmienic, trzeba bylo wyjsc z playera i puscic scene od nowa.
SceneDetail przekazuje teraz cala liste jakosci w params.qualityLinks (label+url+
headers), player pokazuje ikone kolka gdy jest >1 wariant. Przelaczenie robi
replace() i przywraca pozycje sprzed podmiany (replace resetuje czas, wiec
zapisujemy currentTime i ustawiamy go po zaladowaniu), czyli user nie traci
miejsca w filmie. Lista jako overlay, nie Alert - Androidowy AlertDialog renderuje
max 3 przyciski, a jakosci bywa wiecej.
This commit is contained in:
parent
8841327b58
commit
c1ba536607
4 changed files with 84 additions and 2 deletions
|
|
@ -16,6 +16,13 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
id: '2026-07-28b',
|
||||||
|
date: 'July 2026',
|
||||||
|
items: [
|
||||||
|
'Change video quality while watching: tap the gear icon in the player. It keeps your position, so you do not lose your place.',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: '2026-07-28',
|
id: '2026-07-28',
|
||||||
date: 'July 2026',
|
date: 'July 2026',
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,10 @@ export type RootStackParamList = {
|
||||||
refererHost?: string;
|
refererHost?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
mode?: 'video' | 'webview';
|
mode?: 'video' | 'webview';
|
||||||
|
// Lista jakości tego samego wideo (z resolve). Pozwala przełączyć jakość BEZ
|
||||||
|
// wychodzenia z playera (user-request 89845707) — player robi replace() i wraca
|
||||||
|
// na zapamiętaną pozycję. Undefined = brak wyboru (jedno źródło).
|
||||||
|
qualityLinks?: { label: string; url: string; headers?: Record<string, string> }[];
|
||||||
// Headers do bezpośredniego fetchu CDN URL'a (Referer + User-Agent). Mobile
|
// Headers do bezpośredniego fetchu CDN URL'a (Referer + User-Agent). Mobile
|
||||||
// próbuje direct PIERWSZY — bandwidth idzie device→CDN, nie przez VPS proxy.
|
// próbuje direct PIERWSZY — bandwidth idzie device→CDN, nie przez VPS proxy.
|
||||||
// Backend zwraca te headers w StreamLink.headers przy resolve.
|
// Backend zwraca te headers w StreamLink.headers przy resolve.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ import { theme } from '../theme';
|
||||||
import type { StreamLink } from '../types';
|
import type { StreamLink } from '../types';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
|
/** Lista jakości tego samego wideo → przełącznik w playerze (user-request 89845707). */
|
||||||
|
qualityLinks?: { label: string; url: string; headers?: Record<string, string> }[];
|
||||||
url: string;
|
url: string;
|
||||||
sceneId: string;
|
sceneId: string;
|
||||||
// 'tube:<sitetag>' źródła — do telemetrii odtwarzania (ranking źródeł). Opcjonalne;
|
// 'tube:<sitetag>' źródła — do telemetrii odtwarzania (ranking źródeł). Opcjonalne;
|
||||||
|
|
@ -215,6 +217,35 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
}
|
}
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
|
// Zmiana jakości BEZ wychodzenia z playera (user-request 89845707). Podmieniamy
|
||||||
|
// źródło i wracamy na tę samą pozycję — replace() resetuje czas, więc zapisujemy go
|
||||||
|
// przed podmianą i przywracamy po załadowaniu.
|
||||||
|
const qualityLinks = params.qualityLinks;
|
||||||
|
const [qualityOpen, setQualityOpen] = React.useState(false);
|
||||||
|
const [activeQuality, setActiveQuality] = React.useState<string | null>(null);
|
||||||
|
const switchQuality = React.useCallback(
|
||||||
|
(q: { label: string; url: string; headers?: Record<string, string> }) => {
|
||||||
|
setQualityOpen(false);
|
||||||
|
let at = 0;
|
||||||
|
try { at = player.currentTime || 0; } catch { at = 0; }
|
||||||
|
try {
|
||||||
|
player.replace(q.headers ? { uri: q.url, headers: q.headers } : q.url);
|
||||||
|
setActiveQuality(q.label);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (at > 1) player.currentTime = at;
|
||||||
|
player.play();
|
||||||
|
} catch {
|
||||||
|
// player zdążył się rozmontować — nic nie robimy
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[player],
|
||||||
|
);
|
||||||
|
|
||||||
const statusEvent = useEvent(player, 'statusChange', { status: player.status });
|
const statusEvent = useEvent(player, 'statusChange', { status: player.status });
|
||||||
const status = statusEvent?.status ?? player.status;
|
const status = statusEvent?.status ?? player.status;
|
||||||
const playerError = statusEvent?.error;
|
const playerError = statusEvent?.error;
|
||||||
|
|
@ -916,6 +947,11 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
) : (
|
) : (
|
||||||
<View style={{ flex: 1 }} />
|
<View style={{ flex: 1 }} />
|
||||||
)}
|
)}
|
||||||
|
{qualityLinks && qualityLinks.length > 1 ? (
|
||||||
|
<Pressable onPress={() => setQualityOpen(true)} hitSlop={16} style={styles.iconBtn}>
|
||||||
|
<Text style={styles.iconText}>⚙</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
<Pressable onPress={copyLink} hitSlop={16} style={styles.iconBtn}>
|
<Pressable onPress={copyLink} hitSlop={16} style={styles.iconBtn}>
|
||||||
<Text style={styles.iconText}>🔗</Text>
|
<Text style={styles.iconText}>🔗</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
@ -958,6 +994,30 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{qualityOpen && qualityLinks ? (
|
||||||
|
// quality-picker-overlay: lista jakości nad wideo. Bez Modala — Androidowy
|
||||||
|
// AlertDialog renderuje max 3 przyciski, a jakości bywa więcej.
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<Text style={styles.overlayText}>Quality</Text>
|
||||||
|
{qualityLinks.map((q: { label: string; url: string; headers?: Record<string, string> }) => {
|
||||||
|
const on = (activeQuality ?? '') === q.label || (!activeQuality && q.url === url);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={q.label + q.url}
|
||||||
|
onPress={() => switchQuality(q)}
|
||||||
|
style={{ paddingVertical: 10, paddingHorizontal: 22 }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.overlayText, on && { color: theme.accentGlow, fontWeight: '700' }]}>
|
||||||
|
{on ? '✓ ' : ''}{q.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Pressable onPress={() => setQualityOpen(false)} style={{ paddingVertical: 10 }}>
|
||||||
|
<Text style={styles.overlayText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
{status === 'loading' && (
|
{status === 'loading' && (
|
||||||
<View style={styles.overlay} pointerEvents="none">
|
<View style={styles.overlay} pointerEvents="none">
|
||||||
<ActivityIndicator color={theme.fg} size="large" />
|
<ActivityIndicator color={theme.fg} size="large" />
|
||||||
|
|
|
||||||
|
|
@ -597,6 +597,7 @@ function PlaybackButton({
|
||||||
const openAsVideo = async (
|
const openAsVideo = async (
|
||||||
link: StreamLink,
|
link: StreamLink,
|
||||||
fallbackEmbedUrl?: string,
|
fallbackEmbedUrl?: string,
|
||||||
|
allLinks?: StreamLink[],
|
||||||
) => {
|
) => {
|
||||||
let refererHost: string | undefined;
|
let refererHost: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
|
@ -638,6 +639,16 @@ function PlaybackButton({
|
||||||
headers: isDirect && link.headers ? link.headers : undefined,
|
headers: isDirect && link.headers ? link.headers : undefined,
|
||||||
fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined,
|
fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined,
|
||||||
fallbackEmbedUrl,
|
fallbackEmbedUrl,
|
||||||
|
// Pozostałe jakości → przełącznik w playerze (bez powrotu do listy źródeł).
|
||||||
|
qualityLinks: (allLinks && allLinks.length > 1)
|
||||||
|
? allLinks
|
||||||
|
.map((l) => ({
|
||||||
|
label: l.quality || 'auto',
|
||||||
|
url: l.direct_url || l.stream_url || '',
|
||||||
|
headers: l.headers || undefined,
|
||||||
|
}))
|
||||||
|
.filter((l) => !!l.url)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -761,7 +772,7 @@ function PlaybackButton({
|
||||||
const autoPick = pickAuto(directLinks);
|
const autoPick = pickAuto(directLinks);
|
||||||
if (autoPick) {
|
if (autoPick) {
|
||||||
markStarted();
|
markStarted();
|
||||||
await openAsVideo(autoPick, fallbackEmbedUrl);
|
await openAsVideo(autoPick, fallbackEmbedUrl, directLinks);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setQualityLinks(directLinks);
|
setQualityLinks(directLinks);
|
||||||
|
|
@ -824,7 +835,7 @@ function PlaybackButton({
|
||||||
// direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc
|
// direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc
|
||||||
// szukamy embed'a w `links` na resolve response wcześniej; tu już go nie
|
// szukamy embed'a w `links` na resolve response wcześniej; tu już go nie
|
||||||
// mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct).
|
// mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct).
|
||||||
await openAsVideo(link);
|
await openAsVideo(link, undefined, qualityLinks || undefined);
|
||||||
} else if (link.embed_url) {
|
} else if (link.embed_url) {
|
||||||
let refererHost: string | undefined;
|
let refererHost: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue