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
3 changes: 1 addition & 2 deletions src/models/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,7 @@ export interface ITestNotificationMessage extends IMessagable {
}

export interface IPlaySoundMessage extends IMessagable {
source: string;
volume: number;
isFailed: boolean;
}

interface IMessagable {
Expand Down
30 changes: 25 additions & 5 deletions src/notifications/offscreen.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
import { IPlaySoundMessage, PlaySoundMessage } from "../models/messages";
import { getCustomSound, SoundKind } from "../util/sound-storage";

chrome.runtime.onMessage.addListener(message => {
if (message.action === PlaySoundMessage.action) {
playAudio((message as IPlaySoundMessage).source);
}
playNotificationSound((message as IPlaySoundMessage).isFailed);
}
});

// Play sound with access to DOM APIs
function playAudio(source: string) {
// Play the user's custom sound for this outcome if one is stored, otherwise
// fall back to the bundled default. Runs in the offscreen document, which has
// DOM access (Audio, URL.createObjectURL) the service worker lacks.
async function playNotificationSound(isFailed: boolean): Promise<void> {
const kind: SoundKind = isFailed ? "failure" : "success";

let source = `../assets/sounds/${kind}.ogg`;
let objectUrl: string | undefined;
try {
const custom = await getCustomSound(kind);
if (custom) {
objectUrl = URL.createObjectURL(custom.blob);
source = objectUrl;
}
} catch (e) {
console.error("Failed to load custom sound, using default", e);
}

const audio = new Audio(source);
audio.play();
if (objectUrl) {
audio.addEventListener("ended", () => URL.revokeObjectURL(objectUrl!));
}
audio.play().catch(e => console.error("Failed to play notification sound", e));
}
247 changes: 205 additions & 42 deletions src/options/pages/NotificationsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,240 @@
import { useEffect, useRef, useState, CSSProperties } from "react";
import { useSettings } from "../SettingsContext";
import { ITestNotificationMessage, TestNotificationMessage } from "../../models/messages";
import { deleteCustomSound, getCustomSound, saveCustomSound, SoundKind } from "../../util/sound-storage";
import Toggle from "../components/Toggle";

// Custom sounds are kept small; they live in IndexedDB but are still loaded
// into memory to play, so cap the size to keep things snappy.
const MAX_SOUND_BYTES = 1024 * 1024; // 1 MB

const sectionStyle: CSSProperties = {
background: "var(--rta-surface-alt, #f7faf7)",
border: "1px solid var(--rta-border, #b7c9a7)",
borderRadius: 12,
padding: 18,
marginBottom: 18,
};

const sectionTitleStyle: CSSProperties = {
margin: "0 0 14px 0",
fontSize: 13,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
color: "var(--rta-green-dark, #4e6a57)",
};

const rowStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 14,
};

const buttonStyle: CSSProperties = {
border: "1px solid var(--rta-border, #b7c9a7)",
borderRadius: 8,
padding: "6px 14px",
background: "var(--rta-surface, #fff)",
color: "var(--rta-text, #1b241d)",
fontWeight: 500,
cursor: "pointer",
};

const inputStyle: CSSProperties = {
width: 110,
background: "var(--rta-input-bg, #fff)",
color: "var(--rta-text, #1b241d)",
border: "1px solid var(--rta-border, #b7c9a7)",
borderRadius: 8,
padding: "6px 10px",
};

export default function NotificationsPage() {
const { settings, updateSetting, loading } = useSettings();

if (loading || !settings) return <div>Loading...</div>;

const handleTestSuccess = () => {
const sendTest = (isFailed: boolean) => {
chrome.runtime.sendMessage({
action: TestNotificationMessage.action,
title: "Test Notification",
message: "This is a successful notification.",
isFailed: false,
message: isFailed ? "This is a failed notification." : "This is a successful notification.",
isFailed,
popupDurationMs: settings.notificationsDurationMs,
playSound: settings.notificationsSoundEnabled
playSound: settings.notificationsSoundEnabled,
} as ITestNotificationMessage);
};

const handleTestFail = () => {
chrome.runtime.sendMessage({
action: TestNotificationMessage.action,
title: "Test Notification",
message: "This is a failed notification.",
isFailed: true,
popupDurationMs: settings.notificationsDurationMs,
playSound: settings.notificationsSoundEnabled
} as ITestNotificationMessage);
};


return (
<div>
<div style={{ marginBottom: 16 }}>
<Toggle
checked={settings.notificationsEnabled}
onChange={v => updateSetting("notificationsEnabled", v)}
label="Show notifications"
/>
</div>
<div style={{ marginBottom: 16 }}>
<Toggle
checked={settings.notificationsSoundEnabled}
onChange={v => updateSetting("notificationsSoundEnabled", v)}
label="Sound"
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
Display duration (ms):
<div style={{ maxWidth: 640 }}>
<section style={sectionStyle}>
<h3 style={sectionTitleStyle}>Notifications</h3>
<div style={rowStyle}>
<Toggle
checked={settings.notificationsEnabled}
onChange={v => updateSetting("notificationsEnabled", v)}
label="Show notifications"
/>
</div>
<div style={rowStyle}>
<Toggle
checked={settings.notificationsSoundEnabled}
onChange={v => updateSetting("notificationsSoundEnabled", v)}
label="Play a sound"
/>
</div>
<div style={{ ...rowStyle, marginBottom: 0 }}>
<label htmlFor="notif-duration">Display duration</label>
<input
id="notif-duration"
type="number"
min={0}
value={settings.notificationsDurationMs}
onChange={e => updateSetting("notificationsDurationMs", Number(e.target.value))}
style={{ width: 100 }}
style={inputStyle}
/>
</label>
</div>
<div style={{ display: "flex", gap: 16, marginTop: 32 }}>
<span style={{ color: "var(--rta-text-muted, #888)" }}>ms</span>
</div>
</section>

{settings.notificationsSoundEnabled && (
<section style={sectionStyle}>
<h3 style={sectionTitleStyle}>Custom sounds</h3>
<p style={{ marginTop: 0, marginBottom: 16, color: "var(--rta-text-muted, #888)", fontSize: 13 }}>
Pick your own audio files to play when a torrent is added or fails. Leave a slot empty to use the built-in default.
</p>
<SoundPicker kind="success" label="Success sound" />
<SoundPicker kind="failure" label="Failure sound" />
</section>
)}

<div style={{ display: "flex", gap: 12 }}>
<button
onClick={handleTestSuccess}
style={{ background: "var(--rta-success, #228B22)", color: "#fff", border: "none", borderRadius: 8, padding: "8px 18px", fontWeight: 500, cursor: "pointer" }}
onClick={() => sendTest(false)}
style={{ ...buttonStyle, background: "var(--rta-success, #228B22)", color: "#fff", border: "none", padding: "9px 18px" }}
>
Test Success Notification
</button>
<button
onClick={handleTestFail}
style={{ background: "var(--rta-danger, #B22222)", color: "#fff", border: "none", borderRadius: 8, padding: "8px 18px", fontWeight: 500, cursor: "pointer" }}
onClick={() => sendTest(true)}
style={{ ...buttonStyle, background: "var(--rta-danger, #B22222)", color: "#fff", border: "none", padding: "9px 18px" }}
>
Test Failed Notification
</button>
</div>
</div>
);
}

function SoundPicker({ kind, label }: { kind: SoundKind; label: string }) {
const [fileName, setFileName] = useState<string | null>(null);
const [busy, setBusy] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
let active = true;
getCustomSound(kind)
.then(stored => { if (active) setFileName(stored?.name ?? null); })
.catch(() => { if (active) setFileName(null); })
.finally(() => { if (active) setBusy(false); });
return () => { active = false; };
}, [kind]);

const handleFile = async (file: File | undefined) => {
if (!file) return;
if (file.size > MAX_SOUND_BYTES) {
alert(`That sound file is too large (max ${Math.round(MAX_SOUND_BYTES / 1024)} KB).`);
return;
}
setBusy(true);
try {
await saveCustomSound(kind, file, file.name);
setFileName(file.name);
} catch (e) {
console.error(e);
alert("Could not save that sound file.");
} finally {
setBusy(false);
}
};

const reset = async () => {
setBusy(true);
try {
await deleteCustomSound(kind);
setFileName(null);
if (fileInputRef.current) fileInputRef.current.value = "";
} finally {
setBusy(false);
}
};

const preview = async () => {
let src = `../assets/sounds/${kind}.ogg`;
let objectUrl: string | undefined;
try {
const stored = await getCustomSound(kind);
if (stored) {
objectUrl = URL.createObjectURL(stored.blob);
src = objectUrl;
}
const audio = new Audio(src);
if (objectUrl) audio.addEventListener("ended", () => URL.revokeObjectURL(objectUrl!));
await audio.play();
} catch (e) {
if (objectUrl) URL.revokeObjectURL(objectUrl);
console.error(e);
}
};

return (
<div style={{ ...rowStyle, marginBottom: 12 }}>
<span style={{ width: 110, fontWeight: 500 }}>{label}</span>

<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: fileName ? "var(--rta-text, #1b241d)" : "var(--rta-text-muted, #888)",
fontStyle: fileName ? "normal" : "italic",
}}
title={fileName ?? "Default sound"}
>
{busy ? "…" : fileName ?? "Default sound"}
</span>

<button type="button" style={buttonStyle} onClick={preview} disabled={busy} title="Preview">
</button>

<button type="button" style={buttonStyle} onClick={() => fileInputRef.current?.click()} disabled={busy}>
Choose…
</button>

{fileName && (
<button
type="button"
style={{ ...buttonStyle, color: "var(--rta-danger, #B22222)" }}
onClick={reset}
disabled={busy}
title="Reset to default"
>
Reset
</button>
)}

<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={e => handleFile(e.target.files?.[0])}
style={{ display: "none" }}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion src/util/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ function sendTorrentToWebUi(webUi: TorrentWebUI, torrent: Torrent, config: Torre
} else {
showNotification("Torrent adding failed",
`HTTP Response code: ${torrentAddingResult.httpResponseCode}\nResponse body: ${torrentAddingResult.httpResponseBody}`,
false,
true,
settings.notificationsDurationMs,
settings.notificationsSoundEnabled,
webUiUrl);
Expand Down
9 changes: 5 additions & 4 deletions src/util/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,19 @@ export function showNotification(title: string, message: string, isFailed: boole
});

if (playSound) {
playAudioNotification(isFailed).then(() => {
// The offscreen document resolves the actual audio source (a custom
// sound from IndexedDB, or the bundled default) based on isFailed.
ensureOffscreenDocument().then(() => {
const playSoundMessage = {
action: PlaySoundMessage.action,
source: isFailed ? '../assets/sounds/failure.ogg' : '../assets/sounds/success.ogg',
volume: 1
isFailed
} as IPlaySoundMessage;
chrome.runtime.sendMessage(playSoundMessage);
});
}
}

async function playAudioNotification(isFailed: boolean): Promise<void> {
async function ensureOffscreenDocument(): Promise<void> {
if (await chrome.offscreen.hasDocument()) return;
await chrome.offscreen.createDocument({
reasons: ["AUDIO_PLAYBACK"],
Expand Down
Loading