Copy playlist to clipboard, native UI (not alert)

This commit is contained in:
Igor Katson 2024-08-08 09:56:16 +01:00
parent e485844d86
commit 65e4f1b0a6
No known key found for this signature in database
GPG key ID: B4EC22B66D61A3F5
9 changed files with 135 additions and 24 deletions

View file

@ -162,6 +162,7 @@ export interface JSONLogLine {
} }
export interface RqbitAPI { export interface RqbitAPI {
getPlaylistUrl: (index: number) => string | null;
getStreamLogsUrl: () => string | null; getStreamLogsUrl: () => string | null;
listTorrents: () => Promise<ListTorrentsResponse>; listTorrents: () => Promise<ListTorrentsResponse>;
getTorrentDetails: (index: number) => Promise<TorrentDetails>; getTorrentDetails: (index: number) => Promise<TorrentDetails>;

View file

@ -6,8 +6,9 @@ export const IconButton: React.FC<{
className?: string; className?: string;
color?: string; color?: string;
children: any; children: any;
href?: string;
}> = (props) => { }> = (props) => {
const { onClick, disabled, color, children, className, ...otherProps } = const { onClick, disabled, color, children, className, href, ...otherProps } =
props; props;
const onClickStopPropagation: MouseEventHandler<HTMLAnchorElement> = (e) => { const onClickStopPropagation: MouseEventHandler<HTMLAnchorElement> = (e) => {
e.stopPropagation(); e.stopPropagation();
@ -22,7 +23,7 @@ export const IconButton: React.FC<{
<a <a
className={`p-1 text-blue-500 flex items-center justify-center ${colorClassName} ${className}`} className={`p-1 text-blue-500 flex items-center justify-center ${colorClassName} ${className}`}
onClick={onClickStopPropagation} onClick={onClickStopPropagation}
href="#" href={href ?? "#"}
{...otherProps} {...otherProps}
> >
{children} {children}

View file

@ -3,7 +3,13 @@ import { TorrentStats } from "../../api-types";
import { APIContext, RefreshTorrentStatsContext } from "../../context"; import { APIContext, RefreshTorrentStatsContext } from "../../context";
import { IconButton } from "./IconButton"; import { IconButton } from "./IconButton";
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal"; import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
import { FaCog, FaPause, FaPlay, FaTrash, FaClipboardList } from "react-icons/fa"; import {
FaCog,
FaPause,
FaPlay,
FaTrash,
FaClipboardList,
} from "react-icons/fa";
import { useErrorStore } from "../../stores/errorStore"; import { useErrorStore } from "../../stores/errorStore";
export const TorrentActions: React.FC<{ export const TorrentActions: React.FC<{
@ -39,7 +45,7 @@ export const TorrentActions: React.FC<{
text: `Error starting torrent id=${id}`, text: `Error starting torrent id=${id}`,
details: e, details: e,
}); });
}, }
) )
.finally(() => setDisabled(false)); .finally(() => setDisabled(false));
}; };
@ -56,7 +62,7 @@ export const TorrentActions: React.FC<{
text: `Error pausing torrent id=${id}`, text: `Error pausing torrent id=${id}`,
details: e, details: e,
}); });
}, }
) )
.finally(() => setDisabled(false)); .finally(() => setDisabled(false));
}; };
@ -71,6 +77,32 @@ export const TorrentActions: React.FC<{
setDeleting(false); setDeleting(false);
}; };
const playlistUrl = API.getPlaylistUrl(id);
const setAlert = useErrorStore((state) => state.setAlert);
const copyPlaylistUrlToClipboard = async () => {
if (!playlistUrl) {
return;
}
try {
await navigator.clipboard.writeText(playlistUrl);
} catch (e) {
setCloseableError({
text: "Error",
details: { text: `Error copying playlist URL to clipboard: ${e}` },
});
return;
}
setAlert({
text: "Copied",
details: {
text: `Playlist URL copied to clipboard. Paste into e.g. VLC to play.`,
},
});
};
return ( return (
<div className="flex w-full justify-center gap-2 dark:text-slate-300"> <div className="flex w-full justify-center gap-2 dark:text-slate-300">
{canUnpause && ( {canUnpause && (
@ -94,10 +126,11 @@ export const TorrentActions: React.FC<{
<IconButton onClick={startDeleting} disabled={disabled}> <IconButton onClick={startDeleting} disabled={disabled}>
<FaTrash className="hover:text-red-500" /> <FaTrash className="hover:text-red-500" />
</IconButton> </IconButton>
<IconButton onClick={() => {alert("Open this playlist link in external player like VLC")}}> <IconButton
<a target="_blank" href={"/torrents/"+id+"/playlist"}> href={playlistUrl ?? "#"}
<FaClipboardList className="hover:text-green-500"/> onClick={copyPlaylistUrlToClipboard}
</a> >
<FaClipboardList className="hover:text-green-500" />
</IconButton> </IconButton>
<DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} /> <DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} />
</div> </div>

View file

@ -0,0 +1,33 @@
import { ErrorWithLabel } from "../../rqbit-web";
import { useErrorStore } from "../../stores/errorStore";
import { Button } from "../buttons/Button";
import { Modal } from "./Modal";
import { ModalBody } from "./ModalBody";
import { ModalFooter } from "./ModalFooter";
export const AlertModal: React.FC<{}> = () => {
let alert = useErrorStore((store) => store.alert);
let removeAlert = useErrorStore((store) => store.removeAlert);
if (alert) {
return (
<Modal isOpen={true} onClose={removeAlert} title={alert.text}>
<ModalBody>
{alert.details?.statusText && (
<div className="pb-2 text-md">{alert.details?.statusText}</div>
)}
<div className="whitespace-pre-wrap text-sm">
{alert.details?.text}
</div>
</ModalBody>
<ModalFooter>
<Button variant="cancel" onClick={removeAlert}>
Close
</Button>
</ModalFooter>
</Modal>
);
} else {
return <></>;
}
};

View file

@ -151,4 +151,7 @@ export const API: RqbitAPI & { getVersion: () => Promise<string> } = {
} }
return url; return url;
}, },
getPlaylistUrl: (index: number) => {
return apiUrl + `/torrents/${index}/playlist`;
},
}; };

View file

@ -10,6 +10,7 @@ import { Header } from "./components/Header";
import { DarkMode } from "./helper/darkMode"; import { DarkMode } from "./helper/darkMode";
import { useTorrentStore } from "./stores/torrentStore"; import { useTorrentStore } from "./stores/torrentStore";
import { useErrorStore } from "./stores/errorStore"; import { useErrorStore } from "./stores/errorStore";
import { AlertModal } from "./components/modal/AlertModal";
export interface ErrorWithLabel { export interface ErrorWithLabel {
text: string; text: string;
@ -33,16 +34,16 @@ export const RqbitWebUI = (props: {
const setTorrents = useTorrentStore((state) => state.setTorrents); const setTorrents = useTorrentStore((state) => state.setTorrents);
const setTorrentsLoading = useTorrentStore( const setTorrentsLoading = useTorrentStore(
(state) => state.setTorrentsLoading, (state) => state.setTorrentsLoading
); );
const setRefreshTorrents = useTorrentStore( const setRefreshTorrents = useTorrentStore(
(state) => state.setRefreshTorrents, (state) => state.setRefreshTorrents
); );
const refreshTorrents = async () => { const refreshTorrents = async () => {
setTorrentsLoading(true); setTorrentsLoading(true);
let torrents = await API.listTorrents().finally(() => let torrents = await API.listTorrents().finally(() =>
setTorrentsLoading(false), setTorrentsLoading(false)
); );
setTorrents(torrents.torrents); setTorrents(torrents.torrents);
}; };
@ -60,9 +61,9 @@ export const RqbitWebUI = (props: {
setOtherError({ text: "Error refreshing torrents", details: e }); setOtherError({ text: "Error refreshing torrents", details: e });
console.error(e); console.error(e);
return 5000; return 5000;
}, }
), ),
0, 0
); );
}, []); }, []);
@ -86,6 +87,7 @@ export const RqbitWebUI = (props: {
</div> </div>
<LogStreamModal show={logsOpened} onClose={() => setLogsOpened(false)} /> <LogStreamModal show={logsOpened} onClose={() => setLogsOpened(false)} />
<AlertModal />
</div> </div>
); );
}; };

View file

@ -7,6 +7,10 @@ export interface ErrorWithLabel {
} }
export const useErrorStore = create<{ export const useErrorStore = create<{
alert: ErrorWithLabel | null;
setAlert: (alert: ErrorWithLabel) => void;
removeAlert: () => void;
closeableError: ErrorWithLabel | null; closeableError: ErrorWithLabel | null;
setCloseableError: (error: ErrorWithLabel | null) => void; setCloseableError: (error: ErrorWithLabel | null) => void;
@ -18,4 +22,8 @@ export const useErrorStore = create<{
otherError: null, otherError: null,
setOtherError: (otherError) => set(() => ({ otherError })), setOtherError: (otherError) => set(() => ({ otherError })),
alert: null,
setAlert: (alert) => set(() => ({alert})),
removeAlert: () => set(() => ({alert: null}))
})); }));

View file

@ -1881,6 +1881,7 @@ dependencies = [
"serde_with", "serde_with",
"size_format", "size_format",
"tokio", "tokio",
"tokio-socks",
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
"tower-http", "tower-http",
@ -2991,6 +2992,7 @@ dependencies = [
"system-configuration", "system-configuration",
"tokio", "tokio",
"tokio-native-tls", "tokio-native-tls",
"tokio-socks",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
@ -4013,6 +4015,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-socks"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
dependencies = [
"either",
"futures-util",
"thiserror",
"tokio",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.15" version = "0.1.15"

View file

@ -18,7 +18,7 @@ interface InvokeErrorResponse {
} }
function errorToUIError( function errorToUIError(
path: string, path: string
): (e: InvokeErrorResponse) => Promise<never> { ): (e: InvokeErrorResponse) => Promise<never> {
return (e: InvokeErrorResponse) => { return (e: InvokeErrorResponse) => {
console.log(e); console.log(e);
@ -35,11 +35,11 @@ function errorToUIError(
export async function invokeAPI<Response>( export async function invokeAPI<Response>(
name: string, name: string,
params?: InvokeArgs, params?: InvokeArgs
): Promise<Response> { ): Promise<Response> {
console.log("invoking", name, params); console.log("invoking", name, params);
const result = await invoke<Response>(name, params).catch( const result = await invoke<Response>(name, params).catch(
errorToUIError(name), errorToUIError(name)
); );
console.log(result); console.log(result);
return result; return result;
@ -68,16 +68,26 @@ async function readFileAsBase64(file: File): Promise<string> {
} }
export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => { export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
const getHttpBase = () => {
if (!configuration.http_api.listen_addr) {
return null;
}
let port = configuration.http_api.listen_addr.split(":")[1];
if (!port) {
return null;
}
return `http://127.0.0.1:${port}`;
};
let httpBase = getHttpBase();
return { return {
getStreamLogsUrl: () => { getStreamLogsUrl: () => {
if (!configuration.http_api.listen_addr) { if (!httpBase) {
return null; return null;
} }
let port = configuration.http_api.listen_addr.split(":")[1]; return `${httpBase}/stream_logs`;
if (!port) {
return null;
}
return `http://127.0.0.1:${port}/stream_logs`;
}, },
listTorrents: async function (): Promise<ListTorrentsResponse> { listTorrents: async function (): Promise<ListTorrentsResponse> {
return await invokeAPI<ListTorrentsResponse>("torrents_list"); return await invokeAPI<ListTorrentsResponse>("torrents_list");
@ -96,7 +106,7 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
{ {
contents, contents,
opts: opts ?? {}, opts: opts ?? {},
}, }
); );
} }
return await invokeAPI<AddTorrentResponse>("torrent_create_from_url", { return await invokeAPI<AddTorrentResponse>("torrent_create_from_url", {
@ -125,5 +135,11 @@ export const makeAPI = (configuration: RqbitDesktopConfig): RqbitAPI => {
getTorrentStreamUrl: () => { getTorrentStreamUrl: () => {
return ""; return "";
}, },
getPlaylistUrl: (index: number) => {
if (!httpBase) {
return null;
}
return `${httpBase}/torrents/${index}/playlist`;
},
}; };
}; };