Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
qRemote puts your entire qBittorrent server in your pocket. Start, monitor, and finish torrents from anywhere — with live updates every couple of seconds, a polished dark UI, one-tap actions, and built-in search across dozens of indexers. No web UI pinching and zooming, no clunky wrappers: a real native-feeling app designed for your thumb.

<p align="center">
<img src="assets/appstore/01-remote.png" width="300">
<img src="assets/github-landing.jpg" width="300">
</p>

Your torrents at a glance: every card shows status, speed, progress, and ETA in real time, with one-tap pause/resume and swipe actions. Filter by state — All, Active, Done, Paused, Scheduled — search the list instantly, and sort however you want. Adding is just as fast: paste a magnet link, open a `.torrent` file straight from the Files app, or tap **+** and go.

## Manage Torrents

<p align="center">
<img src="assets/appstore/02-modern.png" width="300">
<img src="assets/appstore/iphone_3.jpg" width="300">
</p>

Tap into any torrent for total control. The big three — **Pause**, **Recheck**, **Delete** — sit right at the top, one tap away. Below, everything qBittorrent knows about the torrent, live and editable:
Expand All @@ -31,7 +31,7 @@ No digging through nested menus. Everything about a torrent lives on one screen.
## Manage Transfers

<p align="center">
<img src="assets/appstore/03-interactive.png" width="300">
<img src="assets/appstore/iphone_4.jpg" width="300">
</p>

A live, scrolling upload/download graph keeps you in control of your bandwidth — watch a 61 MiB/s download happen in real time. Then shape it:
Expand All @@ -47,7 +47,7 @@ It's the qBittorrent status bar, reimagined for a phone — and it updates live
## Search Plugin Support

<p align="center">
<img src="assets/appstore/04-search.png" width="300">
<img src="assets/appstore/iphone_5.jpg" width="300">
</p>

Stop hopping between browser tabs. qRemote drives qBittorrent's search plugins directly, so you can search **dozens of indexers at once** and add results in one tap:
Expand All @@ -64,7 +64,7 @@ Search, evaluate, add. Ten seconds, start to finish.
## Server Management

<p align="center">
<img src="assets/appstore/05-settings.png" width="300">
<img src="assets/appstore/iphone_6.jpg" width="300">
</p>

Make qBittorrent behave *your* way, and manage every server you run:
Expand Down
9 changes: 8 additions & 1 deletion app.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
const packageJson = require('./package.json');

// Set via eas.json's "development" build profile `env` block (NOT
// EAS_BUILD_PROFILE — that's only injected in the remote build worker, not
// during eas-cli's local pre-build config resolution, which is when
// credentials/bundle id registration happens). A distinct bundle id lets the
// dev-client build install side-by-side with the App Store build on device.
const isDevelopmentBuild = process.env.APP_VARIANT === 'development';

module.exports = {
expo: {
name: 'qRemote',
Expand All @@ -22,7 +29,7 @@ module.exports = {
// can be rewritten by the next prebuild.
ios: {
supportsTablet: true,
bundleIdentifier: 'com.qRemote.app',
bundleIdentifier: isDevelopmentBuild ? 'com.taylorcox75.expogo' : 'com.qRemote.app',
appStoreUrl: 'https://apps.apple.com/us/app/qremote-for-qbittorrent/id6756276747',
infoPlist: {
// Must be false: RN's StatusBar API (expo-status-bar / FocusAwareStatusBar)
Expand Down
102 changes: 53 additions & 49 deletions app/(tabs)/(torrents)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,12 @@ export default function TorrentsScreen() {
? prefs.expandedCardGridColumns
: 4,
);
} catch (error) {
} catch {
// Use defaults if loading fails
}
};
loadDefaultPreferences();
// Only run once on mount (app launch)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Sync pauseOnAdd from server when connected (best-effort background sync)
Expand Down Expand Up @@ -462,7 +461,7 @@ export default function TorrentsScreen() {

// Sort torrents - efficient O(n log n) with native sort
filtered.sort((a, b) => {
let comparison = 0;
let comparison: number;

switch (sortBy) {
case 'name':
Expand Down Expand Up @@ -894,58 +893,61 @@ export default function TorrentsScreen() {
);

// Scroll handler — header show/hide only
const handleScroll = useCallback((event: { nativeEvent: { contentOffset: { y: number } } }) => {
const currentScrollY = event.nativeEvent.contentOffset.y;
const scrollDifference = currentScrollY - lastScrollY.current;
const handleScroll = useCallback(
(event: { nativeEvent: { contentOffset: { y: number } } }) => {
const currentScrollY = event.nativeEvent.contentOffset.y;
const scrollDifference = currentScrollY - lastScrollY.current;

if (currentScrollY <= 10) {
if (!isHeaderVisible.current) {
isHeaderVisible.current = true;
Animated.timing(headerTranslateY, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start();
}
lastScrollY.current = currentScrollY;
return;
}

if (currentScrollY <= 10) {
if (!isHeaderVisible.current) {
const minMovement = 15;
if (Math.abs(scrollDifference) < minMovement) {
lastScrollY.current = currentScrollY;
return;
}

if (isAnimating.current) {
lastScrollY.current = currentScrollY;
return;
}

if (scrollDifference < -minMovement && !isHeaderVisible.current) {
isAnimating.current = true;
isHeaderVisible.current = true;
Animated.timing(headerTranslateY, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start();
}).start(() => {
isAnimating.current = false;
});
} else if (scrollDifference > minMovement && isHeaderVisible.current) {
isAnimating.current = true;
isHeaderVisible.current = false;
Animated.timing(headerTranslateY, {
toValue: -200,
duration: 200,
useNativeDriver: true,
}).start(() => {
isAnimating.current = false;
});
}
lastScrollY.current = currentScrollY;
return;
}

const minMovement = 15;
if (Math.abs(scrollDifference) < minMovement) {
lastScrollY.current = currentScrollY;
return;
}

if (isAnimating.current) {
lastScrollY.current = currentScrollY;
return;
}

if (scrollDifference < -minMovement && !isHeaderVisible.current) {
isAnimating.current = true;
isHeaderVisible.current = true;
Animated.timing(headerTranslateY, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
isAnimating.current = false;
});
} else if (scrollDifference > minMovement && isHeaderVisible.current) {
isAnimating.current = true;
isHeaderVisible.current = false;
Animated.timing(headerTranslateY, {
toValue: -200,
duration: 200,
useNativeDriver: true,
}).start(() => {
isAnimating.current = false;
});
}

lastScrollY.current = currentScrollY;
}, []);
},
[headerTranslateY],
);

// Whether any secondary (category/tag) filter is active
const hasSecondaryFilter = categoryFilter !== null || tagFilters.length > 0;
Expand Down Expand Up @@ -1227,9 +1229,11 @@ export default function TorrentsScreen() {
style={styles.selectCheckbox}
onPress={() => {
if (selectMode) {
selectedHashes.size === filteredTorrents.length
? clearSelection()
: selectAll();
if (selectedHashes.size === filteredTorrents.length) {
clearSelection();
} else {
selectAll();
}
} else {
toggleSelectMode();
}
Expand Down
24 changes: 12 additions & 12 deletions app/(tabs)/(torrents)/torrent/[hash].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export default function TorrentDetail() {
const [optFlPiece, setOptFlPiece] = useState<boolean | null>(null);
const [optSuperSeeding, setOptSuperSeeding] = useState<boolean | null>(null);
const [optForceStart, setOptForceStart] = useState<boolean | null>(null);
const [optAutoTmm, setOptAutoTmm] = useState<boolean | null>(null);

// ── Data loading ──────────────────────────────────────────────────────

Expand All @@ -191,6 +192,8 @@ export default function TorrentDetail() {
if (hash && isConnected) {
loadTorrentData();
}
// loadTorrentData isn't memoized — only re-run when hash/isConnected change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash, isConnected]);

const pushSpeedSample = (info: TorrentInfo | null | undefined) => {
Expand Down Expand Up @@ -489,18 +492,6 @@ export default function TorrentDetail() {
}
};

const handleCopyHash = async () => {
try {
if (torrent?.hash) {
await Clipboard.setStringAsync(torrent.hash);
haptics.success();
showToast(t('toast.hashCopied'), 'success');
}
} catch (error: unknown) {
showToast(getErrorMessage(error), 'error');
}
};

const handleCopyPath = async (value: string) => {
try {
await Clipboard.setStringAsync(value);
Expand Down Expand Up @@ -716,12 +707,15 @@ export default function TorrentDetail() {
};

const handleAutomaticManagement = async () => {
if (actionLoading) return;
try {
setActionLoading(true);
const isAutoManaged = torrent?.auto_tmm || false;
setOptAutoTmm(!isAutoManaged);
await torrentsApi.setAutomaticTorrentManagement([torrent!.hash], !isAutoManaged);
await new Promise((resolve) => setTimeout(resolve, 250));
await loadTorrentData();
setOptAutoTmm(null);
setActionLoading(false);
showToast(
t('toast.autoManagementToggled', {
Expand All @@ -730,6 +724,7 @@ export default function TorrentDetail() {
'success',
);
} catch (error: unknown) {
setOptAutoTmm(null);
showToast(getErrorMessage(error), 'error');
setActionLoading(false);
}
Expand Down Expand Up @@ -1791,6 +1786,11 @@ export default function TorrentDetail() {
tappableRow(t('torrentDetail.priority'), priorityDisplay, () =>
setPriorityPickerVisible(true),
),
toggleRow(
t('torrentDetail.autoManagement'),
optAutoTmm ?? torrent.auto_tmm ?? false,
handleAutomaticManagement,
),
toggleRow(
t('torrentDetail.sequentialDownload'),
optSeqDl ?? torrent.seq_dl ?? false,
Expand Down
15 changes: 8 additions & 7 deletions app/(tabs)/(torrents)/torrent/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
TouchableOpacity,
ActivityIndicator,
Modal,
Dimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
Expand Down Expand Up @@ -69,12 +68,6 @@ export default function TorrentFilesScreen() {
onConfirm: (value: string) => void;
}>({ title: '', onConfirm: () => {} });

useEffect(() => {
if (hash && isConnected) {
loadFiles();
}
}, [hash, isConnected]);

const loadFiles = async () => {
try {
setLoading(true);
Expand All @@ -100,6 +93,14 @@ export default function TorrentFilesScreen() {
}
};

useEffect(() => {
if (hash && isConnected) {
loadFiles();
}
// loadFiles isn't memoized — only re-run when hash/isConnected change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash, isConnected]);

const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
Expand Down
15 changes: 7 additions & 8 deletions app/(tabs)/(torrents)/torrent/manage-trackers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ export default function ManageTrackersScreen() {
const [editTrackerUrl, setEditTrackerUrl] = useState('');
const [reannouncing, setReannouncing] = useState(false);

useEffect(() => {
fetchTrackers();
}, []);

const fetchTrackers = async () => {
if (!hash) return;
try {
Expand All @@ -73,6 +69,12 @@ export default function ManageTrackersScreen() {
}
};

useEffect(() => {
fetchTrackers();
// Only run once on mount — fetchTrackers isn't memoized.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleRemoveTracker = async (tracker: Tracker) => {
try {
await torrentsApi.removeTrackers(hash!, [tracker.url]);
Expand Down Expand Up @@ -147,10 +149,7 @@ export default function ManageTrackersScreen() {

try {
setAddingTracker(true);
// Remove old tracker
await torrentsApi.removeTrackers(hash!, [editingTracker.url]);
// Add new tracker
await torrentsApi.addTrackers(hash!, [editTrackerUrl.trim()]);
await torrentsApi.editTrackers(hash!, editingTracker.url, editTrackerUrl.trim());
setEditingTracker(null);
setEditTrackerUrl('');
fetchTrackers();
Expand Down
16 changes: 9 additions & 7 deletions app/(tabs)/logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,6 @@ export default function LogsScreen() {
}
}

useEffect(() => {
if (isConnected) {
loadLogs();
}
}, [isConnected, activeTab, filters]);

const loadLogs = async () => {
try {
setLoading(true);
Expand Down Expand Up @@ -95,14 +89,22 @@ export default function LogsScreen() {
setLastPeerLogId(logs[0]?.id);
}
}
} catch (error) {
} catch {
// Ignore log loading errors
} finally {
setLoading(false);
setRefreshing(false);
}
};

useEffect(() => {
if (isConnected) {
loadLogs();
}
// loadLogs isn't memoized — only re-run when these deps change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected, activeTab, filters]);

const handleRefresh = async () => {
setRefreshing(true);
if (activeTab === 'app') {
Expand Down
Loading
Loading