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
2 changes: 2 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,5 @@ jobs:
APPINSIGHTS_CONNECTION_STRING: ${{ secrets.APPINSIGHTS_CONNECTION_STRING }}
GENIUS_ACCESS_TOKEN: ${{ secrets.GENIUS_ACCESS_TOKEN }}
VITE_GITHUB_PAT: ${{ secrets.VITE_GITHUB_PAT }}
ENTRA_CLIENT_ID: ${{ secrets.ENTRA_CLIENT_ID }}
ENTRA_TENANT_ID: ${{ secrets.ENTRA_TENANT_ID }}
21 changes: 15 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"main": "dist/main.js",
"scripts": {
"build:main": "tsc && node -e \"const fs=require('fs');fs.copyFileSync('src/debug-ws.html','dist/debug-ws.html');fs.copyFileSync('src/debug-http.html','dist/debug-http.html');fs.writeFileSync('dist/build-env.json',JSON.stringify({GENIUS_ACCESS_TOKEN:process.env.GENIUS_ACCESS_TOKEN||'',APPINSIGHTS_CONNECTION_STRING:process.env.APPINSIGHTS_CONNECTION_STRING||''}))\"",
"build:main": "tsc && node -e \"require('dotenv').config();const fs=require('fs');fs.copyFileSync('src/debug-ws.html','dist/debug-ws.html');fs.copyFileSync('src/debug-http.html','dist/debug-http.html');fs.writeFileSync('dist/build-env.json',JSON.stringify({GENIUS_ACCESS_TOKEN:process.env.GENIUS_ACCESS_TOKEN||'',APPINSIGHTS_CONNECTION_STRING:process.env.APPINSIGHTS_CONNECTION_STRING||'',ENTRA_CLIENT_ID:process.env.ENTRA_CLIENT_ID||'',ENTRA_TENANT_ID:process.env.ENTRA_TENANT_ID||''}))\"",
"build:renderer": "vite build --config renderer/vite.config.ts",
"build": "npm run build:main && npm run build:renderer",
"dev:renderer": "vite --config renderer/vite.config.ts",
Expand All @@ -32,6 +32,7 @@
"typecheck": "tsc --noEmit && tsc --noEmit --project renderer/tsconfig.json"
},
"dependencies": {
"@azure/msal-node": "^5.2.2",
"applicationinsights": "^3.14.0",
"dompurify": "^3.4.1",
"dotenv": "^17.4.2",
Expand Down
25 changes: 12 additions & 13 deletions renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import { LeaderboardPanel } from './components/LeaderboardPanel';
import { QueuedlePanel } from './components/queuedle/QueuedlePanel';
import { QueueSidebar, type QueueSidebarHandle } from './components/queue/QueueSidebar';
import { MiniPlayerShell } from './components/MiniPlayer';
import { DisplayNameModal } from './components/DisplayNameModal';
import { FeedbackDialog } from './components/FeedbackDialog';
import { ChangelogDialog } from './components/ChangelogDialog';
import { LyricsPanel } from './components/LyricsPanel';
Expand Down Expand Up @@ -89,6 +88,7 @@ function MainApp() {
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [changelogOpen, setChangelogOpen] = useState(false);
const [displayName, setDisplayName] = useState<string | null | undefined>(undefined); // undefined = not yet loaded
const [entraLoading, setEntraLoading] = useState(false);
useEnsureFavourites(displayName);
const [queueDockedWidth, setQueueDockedWidth] = useState<number>(380);
const queueSidebarRef = useRef<QueueSidebarHandle>(null);
Expand All @@ -100,6 +100,9 @@ function MainApp() {
useEffect(() => {
window.sonos.getDisplayName().then(setDisplayName);
window.sonos.getQueueDockedWidth().then(setQueueDockedWidth).catch(() => {});
return window.sonos.onEntraReady(() => {
window.sonos.getDisplayName().then(setDisplayName).catch(() => {});
});
}, []);

useEffect(() => {
Expand Down Expand Up @@ -435,8 +438,9 @@ useEffect(() => {
displayName={displayName}
onSaveName={(name) => {
const stored = name.trim();
window.sonos.setDisplayName(stored).catch(() => {});
setDisplayName(stored || null);
void window.sonos.setDisplayName(stored).then((result) => {
if (!result?.error) setDisplayName(stored || null);
}).catch(() => {});
}}
onChangelogOpen={() => setChangelogOpen(true)}
/>
Expand Down Expand Up @@ -472,7 +476,7 @@ useEffect(() => {
<Route path="/leaderboard" element={<LeaderboardPanel />} />
<Route path="/queuedle" element={<QueuedlePanel />} />
<Route path="/lyrics" element={<LyricsPanel playback={playback} />} />
<Route path="/profile/:userName" element={<ProfilePanel onAddToQueue={handleAddToQueue} displayName={displayName} onSignOut={() => { window.sonos.setDisplayName('').catch(() => {}); setDisplayName(null); }} />} />
<Route path="/profile/:userName" element={<ProfilePanel onAddToQueue={handleAddToQueue} displayName={displayName} onSignOut={async () => { setDisplayName(null); setEntraLoading(true); await window.sonos.entraSignOut().catch(() => {}); await window.sonos.entraReLogin().catch(() => {}); const name = await window.sonos.getDisplayName().catch(() => null); setDisplayName(name); setEntraLoading(false); }} onChangeName={async (name) => { const result = await window.sonos.renameUser(displayName ?? '', name).catch(() => ({ error: 'network' })); if (!result?.error) setDisplayName(name); return result?.error ? { error: result.error } : null; }} />} />
<Route path="/playlist/:id" element={<PlaylistPanel displayName={displayName} onAddToQueue={handleAddToQueue} />} />
</Routes>
<QueueSidebar
Expand Down Expand Up @@ -501,15 +505,10 @@ useEffect(() => {
displayName={displayName}
/>
{toastMsg && <div className={styles.toast}>{toastMsg}</div>}
{displayName === null && (
<DisplayNameModal
onSave={(name) => {
window.sonos.setDisplayName(name).catch(() => {
/* silent */
});
setDisplayName(name);
}}
/>
{entraLoading && (
<div className={styles.entraSigningIn}>
<span>Signing in with your organisation account…</span>
</div>
)}
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
{changelogOpen && <ChangelogDialog onClose={() => setChangelogOpen(false)} />}
Expand Down
29 changes: 20 additions & 9 deletions renderer/src/components/DisplayNameModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,26 @@ import { useState } from 'react';
import styles from '../styles/DisplayNameModal.module.css';

interface Props {
onSave: (name: string) => void;
onSave: (name: string) => Promise<{ error: string } | null>;
defaultName?: string;
}

export function DisplayNameModal({ onSave }: Props) {
const [value, setValue] = useState('');
export function DisplayNameModal({ onSave, defaultName }: Props) {
const [value, setValue] = useState(defaultName ?? '');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);

const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (trimmed) onSave(trimmed);
if (!trimmed) return;
setSaving(true);
setError(null);
const result = await onSave(trimmed);
setSaving(false);
if (result?.error === 'taken') {
setError('That name is already taken — try another.');
}
};

return (
Expand All @@ -20,21 +30,22 @@ export function DisplayNameModal({ onSave }: Props) {
<div className={styles.icon}>🎵</div>
<h2 className={styles.heading}>What should we call you?</h2>
<p className={styles.sub}>
Your name shows on tracks you add to the shared queue.
Your name shows on tracks you add to the shared queue. Defaults to your organisation account name.
</p>
<form onSubmit={handleSubmit} className={styles.form}>
<input
className={styles.input}
type="text"
placeholder="Your name"
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => { setValue(e.target.value); setError(null); }}
autoFocus
maxLength={32}
spellCheck={false}
/>
<button className={styles.btn} type="submit" disabled={!value.trim()}>
Let's go
{error && <p className={styles.error}>{error}</p>}
<button className={styles.btn} type="submit" disabled={!value.trim() || saving}>
{saving ? 'Saving…' : "Let's go"}
</button>
</form>
</div>
Expand Down
122 changes: 98 additions & 24 deletions renderer/src/components/PlayerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
PictureInPicture2,
MicVocal,
Heart,
ChevronUp,
ChevronDown,
} from "lucide-react";
import type { PlaybackState } from "../hooks/usePlayback";
import styles from "../styles/PlayerBar.module.css";
Expand Down Expand Up @@ -84,40 +86,92 @@ function VolumeButton({ volume }: { volume: number }) {
const [open, setOpen] = useState(false);
const [localVol, setLocalVol] = useState(volume);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const preMuteRef = useRef<number>(volume > 0 ? volume : 50);

// Sync incoming WS volume only when the user isn't actively dragging
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setLocalVol(volume);
}, [volume]);

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setLocalVol(val);
const commitVolume = (val: number) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
getActiveProvider().setVolume(val);
dragging.current = false;
}, 150);
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setLocalVol(val);
if (val > 0) preMuteRef.current = val;
commitVolume(val);
};

const step = (delta: number) => {
setLocalVol((prev) => {
const next = Math.max(0, Math.min(100, prev + delta));
if (next === prev) return prev;
if (next > 0) preMuteRef.current = next;
commitVolume(next);
return next;
});
};

const toggleMute = () => {
if (localVol > 0) {
preMuteRef.current = localVol;
setLocalVol(0);
commitVolume(0);
} else {
const restored = preMuteRef.current > 0 ? preMuteRef.current : 50;
setLocalVol(restored);
commitVolume(restored);
}
};

const handleEnter = () => {
if (closeRef.current) {
clearTimeout(closeRef.current);
closeRef.current = null;
}
setOpen(true);
};

const handleLeave = () => {
if (closeRef.current) clearTimeout(closeRef.current);
closeRef.current = setTimeout(() => setOpen(false), 150);
};

useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
return () => {
if (closeRef.current) clearTimeout(closeRef.current);
if (debounceRef.current) clearTimeout(debounceRef.current);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
}, []);

const muted = localVol === 0;

return (
<div ref={wrapRef} className={styles.volWrap}>
<div
className={styles.volWrap}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{open && (
<div className={styles.volPopover}>
<span className={styles.volPct}>{localVol}</span>
<button
className={styles.volStep}
onClick={() => step(+1)}
disabled={localVol >= 100}
title="Volume up"
aria-label="Volume up"
>
<ChevronUp size={14} />
</button>
<input
className={styles.volSliderV}
type="range"
Expand All @@ -129,12 +183,22 @@ function VolumeButton({ volume }: { volume: number }) {
}}
onChange={handleChange}
/>
<button
className={styles.volStep}
onClick={() => step(-1)}
disabled={localVol <= 0}
title="Volume down"
aria-label="Volume down"
>
<ChevronDown size={14} />
</button>
</div>
)}
<button
className={styles.ctrl}
onClick={() => setOpen((o) => !o)}
title="Volume"
onClick={toggleMute}
title={muted ? "Unmute" : "Mute"}
aria-label={muted ? "Unmute" : "Mute"}
>
<VolumeIconGlyph volume={localVol} />
</button>
Expand All @@ -151,7 +215,7 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props)
displayTrack, displayArtist, cachedArt, dominantColor,
elapsedLabel, durationLabel, progressPct, durationMs,
isPlaying, isVisible, shuffle, repeat, volume, isExplicit,
albumItem, prefetchAlbum,
albumItem, artistItem, prefetchAlbum,
currentObjectId, currentServiceId, currentAccountId, artUrlRaw,
} = useNowPlaying(playback);

Expand Down Expand Up @@ -218,17 +282,27 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props)
)}
</div>
<div className={styles.trackInfo}>
<div
style={{
display: "flex",
alignItems: "center",
overflow: "hidden",
}}
>
<div className={styles.trackLine}>
<ScrollingText
text={`${displayTrack || "—"}${displayArtist ? ` - ${displayArtist}` : ""}`}
text={displayTrack || "—"}
className={styles.trackName}
/>
{displayArtist && (
<>
<span className={styles.trackSep}>{' – '}</span>
{artistItem ? (
<button
type="button"
className={styles.trackArtist}
onClick={() => openItem(artistItem)}
>
{displayArtist}
</button>
) : (
<span className={styles.trackArtist}>{displayArtist}</span>
)}
</>
)}
{isExplicit && <ExplicitBadge />}
</div>
<div className={styles.progressSection}>
Expand Down
Loading
Loading