Skip to content

Commit da704ad

Browse files
committed
fix(loxone): a T5 double-click starts the music that is already there
Loxone's button 2 is documented as "double-click starts playback or chooses next favorite", and starting comes first. We only ever cycled favourites, so a zone that was paused on a Spotify track with no room favourites configured answered the double-click with nothing at all — leaving only the single click's volume step visible, which is what the zone looked like it was doing (#381). Keyed off the zone's audiopath because that is exactly what the resume path falls back to, so the check cannot promise a resume the zone then fails to perform. A zone that is already playing, or has nothing to resume, cycles favourites as before. Also let the route tolerate a trailing slash, like every route around it.
1 parent 1f87e9e commit da704ad

3 files changed

Lines changed: 102 additions & 2 deletions

File tree

src/adapters/loxone/commands/handlers/zoneHandlers.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,19 @@ async function audioRoomFavPlus(
475475
) {
476476
const parts = splitCommand(command);
477477
const zoneId = parseNumberPart(parts[1], 0);
478+
const state = zoneManager.getState(zoneId);
479+
480+
// Loxone describes this button as "double-click starts playback or chooses next favorite", and
481+
// starting comes first: a zone sitting on a track it can resume is asking to be un-paused, not to
482+
// be thrown onto a radio station. Keyed off `audiopath` because that is exactly what the resume
483+
// path falls back to, so the check cannot promise a resume the zone then fails to perform. Without
484+
// it, a zone with no room favourites answered the double-click with nothing at all (#381), which
485+
// left only the single-click volume step visible.
486+
if (state && state.mode !== 'play' && state.audiopath) {
487+
zoneManager.handleCommand(zoneId, 'play');
488+
return buildEmptyResponse(command);
489+
}
490+
478491
const favorites = await favoritesManager.get(zoneId);
479492
if (!favorites.items.length) {
480493
return buildEmptyResponse(command);
@@ -484,7 +497,6 @@ async function audioRoomFavPlus(
484497
if (!metadata) {
485498
return buildEmptyResponse(command);
486499
}
487-
const state = zoneManager.getState(zoneId);
488500

489501
const lastFavoriteId = metadata.lastFavoriteId as number | undefined;
490502
let currentIndex = -1;

src/adapters/loxone/commands/router/routeRegistry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export function registerRoutes(
170170
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/playlist\/play\/.+$/, zoneHandlers.audioPlaylistPlay);
171171
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/library\/play\/.+$/, zoneHandlers.audioLibraryPlay);
172172
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/roomfav\/play\//, zoneHandlers.audioFavoritePlay);
173-
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/roomfav\/plus$/, zoneHandlers.audioRoomFavPlus);
173+
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/roomfav\/plus\/?$/, zoneHandlers.audioRoomFavPlus);
174174
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/playurl\/.+$/, zoneHandlers.audioPlayUrl);
175175
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/equalizersettings\/[^/]+\/?$/, zoneHandlers.audioEqualizerSettings);
176176
router.registerRegex('audio', /^audio\/(?:cfg\/)?\d+\/linein(?:\/.*)?$/, inputHandlers.audioLineIn);

tests/zoneHandlers.roomfavs.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,91 @@ test('audio cfg roomfavs add returns created favorite id (not zone id)', async (
3030
assert.equal(payload.id, 42);
3131
assert.equal(payload.name, 'Test Favorite');
3232
});
33+
34+
test('roomfav plus resumes a paused zone instead of jumping to a favorite', async () => {
35+
const commands: Array<{ zoneId: number; command: string }> = [];
36+
const zoneHandlers = createZoneHandlers(
37+
{
38+
getState: () => ({ id: 7, mode: 'pause', audiopath: 'spotify:track:abc' }),
39+
getQueue: () => ({ id: 7, items: [], shuffle: false, start: 0, totalitems: 0 }),
40+
handleCommand: (zoneId: number, command: string) => {
41+
commands.push({ zoneId, command });
42+
},
43+
queue: { setPendingShuffle: () => {}, seekInQueue: () => false },
44+
playContent: async () => {},
45+
getMetadata: () => ({}),
46+
} as any,
47+
{ get: async () => ({}) } as any,
48+
{
49+
get: async () => ({ items: [{ id: 1, audiopath: 'radio:one' }] }),
50+
getForPlayback: async () => ({ id: 1, audiopath: 'radio:one', title: 'One' }),
51+
} as any,
52+
{ resolveMetadata: async () => null } as any,
53+
);
54+
55+
await zoneHandlers.audioRoomFavPlus('audio/7/roomfav/plus');
56+
assert.deepEqual(commands, [{ zoneId: 7, command: 'play' }]);
57+
});
58+
59+
test('roomfav plus still cycles favorites when the zone is already playing', async () => {
60+
const played: number[] = [];
61+
const metadata: Record<string, unknown> = {};
62+
const zoneHandlers = createZoneHandlers(
63+
{
64+
getState: () => ({ id: 7, mode: 'play', audiopath: 'radio:one' }),
65+
getQueue: () => ({ id: 7, items: [], shuffle: false, start: 0, totalitems: 0 }),
66+
handleCommand: () => {
67+
throw new Error('should not resume while playing');
68+
},
69+
queue: { setPendingShuffle: () => {}, seekInQueue: () => false },
70+
playContent: async (zoneId: number) => {
71+
played.push(zoneId);
72+
},
73+
getMetadata: () => metadata,
74+
} as any,
75+
{ get: async () => ({}) } as any,
76+
{
77+
get: async () => ({
78+
items: [
79+
{ id: 1, audiopath: 'radio:one' },
80+
{ id: 2, audiopath: 'radio:two' },
81+
],
82+
}),
83+
getForPlayback: async (_zoneId: number, id: number) => ({
84+
id,
85+
audiopath: id === 2 ? 'radio:two' : 'radio:one',
86+
title: 'Fav',
87+
}),
88+
} as any,
89+
{ resolveMetadata: async () => null } as any,
90+
);
91+
92+
await zoneHandlers.audioRoomFavPlus('audio/7/roomfav/plus');
93+
assert.equal(metadata.lastFavoriteId, 2);
94+
assert.deepEqual(played, [7]);
95+
});
96+
97+
test('roomfav plus falls back to a favorite when a stopped zone has nothing to resume', async () => {
98+
const metadata: Record<string, unknown> = {};
99+
const zoneHandlers = createZoneHandlers(
100+
{
101+
getState: () => ({ id: 7, mode: 'stop', audiopath: '' }),
102+
getQueue: () => ({ id: 7, items: [], shuffle: false, start: 0, totalitems: 0 }),
103+
handleCommand: () => {
104+
throw new Error('should not resume without an audiopath');
105+
},
106+
queue: { setPendingShuffle: () => {}, seekInQueue: () => false },
107+
playContent: async () => {},
108+
getMetadata: () => metadata,
109+
} as any,
110+
{ get: async () => ({}) } as any,
111+
{
112+
get: async () => ({ items: [{ id: 5, audiopath: 'radio:one' }] }),
113+
getForPlayback: async () => ({ id: 5, audiopath: 'radio:one', title: 'One' }),
114+
} as any,
115+
{ resolveMetadata: async () => null } as any,
116+
);
117+
118+
await zoneHandlers.audioRoomFavPlus('audio/7/roomfav/plus');
119+
assert.equal(metadata.lastFavoriteId, 5);
120+
});

0 commit comments

Comments
 (0)