1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt, ' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety; zakresy 0–5 i A–Z na dywiz. Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach, czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu, tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana. Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie). Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika to zero zmiany. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
5.7 KiB
TypeScript
182 lines
5.7 KiB
TypeScript
// Bottom-sheet wybór jakości po resolve. Sortuje malejąco po pikselach (4K > 1080 > 720 > ...).
|
|
// Single quality → caller pomija modal i odpala od razu.
|
|
import React from 'react';
|
|
import {
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
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 {
|
|
if (!q) return 0;
|
|
const s = q.toLowerCase();
|
|
if (s.includes('4k') || s.includes('uhd')) return 2160;
|
|
if (s.includes('2k') || s.includes('qhd')) return 1440;
|
|
const m = s.match(/\d+/);
|
|
if (m) return parseInt(m[0], 10);
|
|
if (s.includes('fhd')) return 1080;
|
|
if (s.includes('hd')) return 720;
|
|
if (s.includes('sd')) return 480;
|
|
return 0;
|
|
}
|
|
|
|
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,
|
|
onSelect,
|
|
onCancel,
|
|
title = 'Select quality',
|
|
/**
|
|
* preserveOrder=true → nie sortuj (zachowaj kolejność z `links`). Używane dla
|
|
* paradisehill multipart picker gdzie chcemy Part 1..N rosnąco, nie po "quality"
|
|
* (qualityToInt("Part 35")=35 sortowałby części od końca).
|
|
*/
|
|
preserveOrder = false,
|
|
}: {
|
|
visible: boolean;
|
|
links: StreamLink[];
|
|
onSelect: (link: StreamLink) => void;
|
|
onCancel: () => void;
|
|
title?: string;
|
|
preserveOrder?: boolean;
|
|
}) {
|
|
const sorted = React.useMemo(
|
|
() => (preserveOrder ? links : sortByQualityDesc(links)),
|
|
[links, preserveOrder],
|
|
);
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
transparent
|
|
animationType="fade"
|
|
onRequestClose={onCancel}
|
|
>
|
|
<Pressable style={styles.backdrop} onPress={onCancel}>
|
|
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
|
|
<Text style={styles.title}>{title}</Text>
|
|
{/* ScrollView: paradisehill movie może mieć 35+ parts, bez scrolla sheet
|
|
przekraczał ekran i część była nieosiągalna. (Android Alert.alert capował
|
|
do 3 buttonów, bug-report 2ebd0690 2026-06-07 "35 parts, 3 w popup".) */}
|
|
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
|
|
{sorted.map((link, i) => {
|
|
const px = qualityToInt(link.quality);
|
|
return (
|
|
<Pressable
|
|
key={`${link.stream_url || link.embed_url}-${i}`}
|
|
style={styles.row}
|
|
onPress={() => onSelect(link)}
|
|
>
|
|
<Text style={styles.rowQuality}>
|
|
{link.quality || 'unknown'}
|
|
</Text>
|
|
<Text style={styles.rowMeta}>
|
|
{px >= 144 ? `${px}p` : ''}
|
|
{link.type ? ` · ${shortType(link.type)}` : ''}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
<Pressable style={styles.cancel} onPress={onCancel}>
|
|
<Text style={styles.cancelText}>Cancel</Text>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function shortType(t: string): string {
|
|
const low = t.toLowerCase();
|
|
if (low.includes('mp4') || low === 'direct') return 'mp4';
|
|
if (low.includes('mpegurl') || low.includes('m3u8')) return 'hls';
|
|
if (low.includes('dash')) return 'dash';
|
|
if (low.includes('webm')) return 'webm';
|
|
return t;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
backdrop: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
justifyContent: 'flex-end',
|
|
},
|
|
sheet: {
|
|
backgroundColor: theme.bg,
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
paddingHorizontal: 16,
|
|
paddingTop: 14,
|
|
paddingBottom: 24,
|
|
borderTopColor: theme.border,
|
|
borderTopWidth: 1,
|
|
},
|
|
list: { maxHeight: 380 },
|
|
listContent: { paddingBottom: 2 },
|
|
title: {
|
|
color: theme.muted,
|
|
fontSize: 12,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 0.5,
|
|
marginBottom: 10,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 8,
|
|
padding: 14,
|
|
marginBottom: 8,
|
|
},
|
|
rowQuality: { color: theme.fg, fontSize: 16, fontWeight: '600' },
|
|
rowMeta: { color: theme.muted, fontSize: 13 },
|
|
cancel: {
|
|
marginTop: 6,
|
|
padding: 12,
|
|
alignItems: 'center',
|
|
},
|
|
cancelText: { color: theme.muted, fontSize: 14 },
|
|
});
|