feat(mobile): quick-play ▶ on scene thumbnails
Some checks are pending
Backend tests / test (push) Waiting to run

Tap the ▶ overlay on a tile to play the best source without opening SceneDetail first
(user request: fewer steps). It routes to SceneDetail with an autoplay flag that fires the
top source's existing resolve on mount — reusing all the phone-side/backend resolve,
quality-picker and fallback handling in place rather than duplicating it. With a default
quality set (Settings → Playback) this is one tap to video; otherwise the quality chooser
still appears. ▶ is hidden in duplicate-select mode; tap elsewhere on the tile = details.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-07-01 10:45:50 +02:00
parent 93f4d05df8
commit 64aee54fb6
4 changed files with 56 additions and 3 deletions

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-01b',
date: 'July 2026',
items: [
'Quick play: tap the ▶ on any thumbnail to start the best source right away — with a default quality set (Settings → Playback) it is one tap to video.',
],
},
{ {
id: '2026-07-01', id: '2026-07-01',
date: 'July 2026', date: 'July 2026',

View file

@ -142,6 +142,18 @@ function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }:
<Text style={styles.durText}>{durLabel}</Text> <Text style={styles.durText}>{durLabel}</Text>
</View> </View>
) : null} ) : null}
{/* Quick-play (user-request): tap = od razu graj najlepsze źródło (bez wchodzenia
na SceneDetail). Autoplay resolvuje top-source na mount; z ustawioną domyślną
jakością to 1 tap wideo. Tap poza = szczegóły. Ukryte w trybie wyboru dup. */}
{!isSelecting ? (
<Pressable
style={styles.playBtn}
hitSlop={6}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id, autoplay: true })}
>
<Text style={styles.playBtnIcon}></Text>
</Pressable>
) : null}
</View> </View>
<Text style={[styles.title, dim && styles.titleDim]} numberOfLines={1}> <Text style={[styles.title, dim && styles.titleDim]} numberOfLines={1}>
{scene.title} {scene.title}
@ -226,6 +238,22 @@ const styles = StyleSheet.create({
paddingVertical: 2, paddingVertical: 2,
borderRadius: 4, borderRadius: 4,
}, },
playBtn: {
position: 'absolute',
left: '50%',
top: '50%',
marginLeft: -18,
marginTop: -18,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(0,0,0,0.5)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
playBtnIcon: { color: '#fff', fontSize: 15, marginLeft: 2 },
durText: { durText: {
color: theme.fg, color: theme.fg,
fontSize: 11, fontSize: 11,

View file

@ -37,7 +37,8 @@ export type RootStackParamList = {
// do title bara (np. 'hqporner.com'). // do title bara (np. 'hqporner.com').
SiteScenes: { origin: string; name: string }; SiteScenes: { origin: string; name: string };
MovieDetail: { id: string }; MovieDetail: { id: string };
SceneDetail: { id: string }; // autoplay: wejście z ▶ na kafelku (quick-play) — SceneDetail sam odpala najlepsze źródło.
SceneDetail: { id: string; autoplay?: boolean };
Performers: undefined; Performers: undefined;
// `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen // `seenSince` (ISO timestamp): jeśli ten performer jest favorited, FavoritesScreen
// przekazuje `last_seen_at` SPRZED markFavoriteSeen. Sceny z `created_at > seenSince` // przekazuje `last_seen_at` SPRZED markFavoriteSeen. Sceny z `created_at > seenSince`

View file

@ -36,7 +36,7 @@ export function SceneDetailScreen() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const route = useRoute<RouteProp<RootStackParamList, 'SceneDetail'>>(); const route = useRoute<RouteProp<RootStackParamList, 'SceneDetail'>>();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>(); const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
const { id } = route.params; const { id, autoplay } = route.params;
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['scene', id], queryKey: ['scene', id],
@ -362,12 +362,14 @@ export function SceneDetailScreen() {
{data.playback_sources.length > 0 && ( {data.playback_sources.length > 0 && (
<Section title="Watch"> <Section title="Watch">
{data.playback_sources.map((p) => ( {data.playback_sources.map((p, i) => (
<PlaybackButton <PlaybackButton
key={p.id} key={p.id}
sceneId={data.id} sceneId={data.id}
sceneDurationSec={data.duration_sec ?? null} sceneDurationSec={data.duration_sec ?? null}
source={p} source={p}
// Quick-play: pierwsze źródło (backend-ranked native-first) odpala się samo.
autoPlay={!!autoplay && i === 0}
/> />
))} ))}
<Text style={styles.tagHint}>long-press: open in browser / mark as broken</Text> <Text style={styles.tagHint}>long-press: open in browser / mark as broken</Text>
@ -472,10 +474,12 @@ function PlaybackButton({
sceneId, sceneId,
sceneDurationSec, sceneDurationSec,
source, source,
autoPlay = false,
}: { }: {
sceneId: string; sceneId: string;
sceneDurationSec: number | null; sceneDurationSec: number | null;
source: PlaybackSource; source: PlaybackSource;
autoPlay?: boolean;
}) { }) {
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -743,6 +747,19 @@ function PlaybackButton({
} }
}; };
// Quick-play z kafelka (▶ → SceneDetail autoplay): odpal to źródło raz na mount.
// onPress ma pełną logikę resolve (phone-side/backend + quality picker + fallbacki),
// więc reużywamy ją w kontekście SceneDetail zamiast duplikować gdziekolwiek indziej.
const didAutoPlayRef = React.useRef(false);
React.useEffect(() => {
if (autoPlay && !didAutoPlayRef.current) {
didAutoPlayRef.current = true;
void onPress();
}
// onPress celowo poza deps — strzał raz na mount, nie przy każdym re-renderze.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPlay]);
const onSelectQuality = async (link: StreamLink) => { const onSelectQuality = async (link: StreamLink) => {
setQualityLinks(null); setQualityLinks(null);
try { try {