feat(mobile): quick-play ▶ on scene thumbnails
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:
parent
129c71cf95
commit
17d82d465a
4 changed files with 56 additions and 3 deletions
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue