mirror of
https://github.com/joBr99/nspanel-lovelace-ui.git
synced 2025-12-19 22:24:15 +01:00
v4.3.3.40 - Update NsPanelTs.ts
- Fix maxColorTempValue for RGB - Fix SqueezeboxRPC-Media-Player and add some Functions
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/*-----------------------------------------------------------------------
|
||||
TypeScript v4.3.3.39 zur Steuerung des SONOFF NSPanel mit dem ioBroker by @Armilar / @TT-Tom / @ticaki / @Britzelpuf / @Sternmiere / @ravenS0ne
|
||||
TypeScript v4.3.3.40 zur Steuerung des SONOFF NSPanel mit dem ioBroker by @Armilar / @TT-Tom / @ticaki / @Britzelpuf / @Sternmiere / @ravenS0ne
|
||||
- abgestimmt auf TFT 53 / v4.3.3 / BerryDriver 9 / Tasmota 13.3.0
|
||||
@joBr99 Projekt: https://github.com/joBr99/nspanel-lovelace-ui/tree/main/ioBroker
|
||||
NsPanelTs.ts (dieses TypeScript in ioBroker) Stable: https://github.com/joBr99/nspanel-lovelace-ui/blob/main/ioBroker/NsPanelTs.ts
|
||||
@@ -107,7 +107,9 @@ ReleaseNotes:
|
||||
- 20.01.2024 - v4.3.3.38 Add: click on indicatorIcon navigate to Page
|
||||
- 23.01.2024 - v4.3.3.39 Add: Optional setOn & setOff for HW button with mode 'set'
|
||||
- 28.01.2024 - v4.3.3.39 Fix: ack for read-only state
|
||||
|
||||
- 03.02.2024 - v4.3.3.40 Fix: RGB maxValueColorTemp
|
||||
- 05.02.2024 - v4.3.3.40 Fix SqueezeboxRPC-Media-Player and add some Functions
|
||||
|
||||
|
||||
Todo:
|
||||
- XX.XX.XXXX - v5.0.0 Change the bottomScreensaverEntity (rolling) if more than 6 entries are defined
|
||||
@@ -974,7 +976,7 @@ export const config: Config = {
|
||||
// _________________________________ DE: Ab hier keine Konfiguration mehr _____________________________________
|
||||
// _________________________________ EN: No more configuration from here _____________________________________
|
||||
|
||||
const scriptVersion: string = 'v4.3.3.39';
|
||||
const scriptVersion: string = 'v4.3.3.40';
|
||||
const tft_version: string = 'v4.3.3';
|
||||
const desired_display_firmware_version = 53;
|
||||
const berry_driver_version = 9;
|
||||
@@ -4754,12 +4756,12 @@ function unsubscribeMediaSubscriptions(): void {
|
||||
|
||||
function subscribeMediaSubscriptions(id: string): void {
|
||||
on({id: [id + '.STATE',
|
||||
id + '.VOLUME',
|
||||
id + '.ARTIST',
|
||||
id + '.ALBUM',
|
||||
id + '.TITLE',
|
||||
id + '.REPEAT',
|
||||
id + '.SHUFFLE'], change: "any"}, async function () {
|
||||
id + '.VOLUME',
|
||||
id + '.ARTIST',
|
||||
id + '.ALBUM',
|
||||
id + '.TITLE',
|
||||
id + '.REPEAT',
|
||||
id + '.SHUFFLE'], change: "any"}, async function () {
|
||||
(function () { if (timeoutMedia) { clearTimeout(timeoutMedia); timeoutMedia = null; } })();
|
||||
timeoutMedia = setTimeout(async function () {
|
||||
if (useMediaEvents) {
|
||||
@@ -4774,8 +4776,8 @@ function subscribeMediaSubscriptions(id: string): void {
|
||||
|
||||
function subscribeMediaSubscriptionsSonosAdd(id: string): void {
|
||||
on({id: [id + '.QUEUE',
|
||||
id + '.DURATION',
|
||||
id + '.ELAPSED'], change: "any"}, async function () {
|
||||
id + '.DURATION',
|
||||
id + '.ELAPSED'], change: "any"}, async function () {
|
||||
(function () { if (timeoutMedia) { clearTimeout(timeoutMedia); timeoutMedia = null; } })();
|
||||
timeoutMedia = setTimeout(async function () {
|
||||
if (useMediaEvents) {
|
||||
@@ -4790,7 +4792,7 @@ function subscribeMediaSubscriptionsSonosAdd(id: string): void {
|
||||
|
||||
function subscribeMediaSubscriptionsAlexaAdd(id: string): void {
|
||||
on({id: [id + '.DURATION',
|
||||
id + '.ELAPSED'], change: "any"}, async function () {
|
||||
id + '.ELAPSED'], change: "any"}, async function () {
|
||||
(function () { if (timeoutMedia) { clearTimeout(timeoutMedia); timeoutMedia = null; } })();
|
||||
timeoutMedia = setTimeout(async function () {
|
||||
if (useMediaEvents) {
|
||||
@@ -4805,7 +4807,21 @@ function subscribeMediaSubscriptionsAlexaAdd(id: string): void {
|
||||
|
||||
function subscribeMediaSubscriptionsBoseAdd(id: string): void {
|
||||
on({id: [id + '.DURATION',
|
||||
id + '.ELAPSED'], change: "any"}, async function () {
|
||||
id + '.ELAPSED'], change: "any"}, async function () {
|
||||
(function () { if (timeoutMedia) { clearTimeout(timeoutMedia); timeoutMedia = null; } })();
|
||||
timeoutMedia = setTimeout(async function () {
|
||||
if (useMediaEvents) {
|
||||
GeneratePage(activePage!);
|
||||
setTimeout(async function () {
|
||||
GeneratePage(activePage!);
|
||||
}, 50);
|
||||
}
|
||||
},50)
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeMediaSubscriptionsSqueezeboxAdd(id: string): void {
|
||||
on({id: [id + '.ELAPSED'], change: "any"}, async function () {
|
||||
(function () { if (timeoutMedia) { clearTimeout(timeoutMedia); timeoutMedia = null; } })();
|
||||
timeoutMedia = setTimeout(async function () {
|
||||
if (useMediaEvents) {
|
||||
@@ -5010,13 +5026,14 @@ async function createAutoMediaAlias (id: string, mediaDevice: string, adapterPla
|
||||
await createAliasAsync(id + '.STATE', dpPath + '.Power', true, <iobJS.StateCommon> {type: 'number', role: 'switch', name: 'STATE'});
|
||||
await createAliasAsync(id + '.VOLUME', dpPath + '.Volume', true, <iobJS.StateCommon> {type: 'number', role: 'level.volume', name: 'VOLUME'});
|
||||
await createAliasAsync(id + '.VOLUME_ACTUAL', dpPath + '.Volume', true, <iobJS.StateCommon> {type: 'number', role: 'value.volume', name: 'VOLUME_ACTUAL'});
|
||||
await createAliasAsync(id + '.SHUFFLE', dpPath + '.PlaylistShuffle', true, <iobJS.StateCommon> {type: 'string', role: 'media.mode.shuffle', name: 'SHUFFLE', alias: {id: dpPath + '.PlaylistShuffle', read: 'val !== 0 ? \'on\' : \'off\'', write: 'val === \'off\' ? 0 : 1'}});
|
||||
await createAliasAsync(id + '.SHUFFLE', dpPath + '.PlaylistShuffle', true, <iobJS.StateCommon> {type: 'string', role: 'media.mode.shuffle', name: 'SHUFFLE'});
|
||||
await createAliasAsync(id + '.REPEAT', dpPath + '.PlaylistRepeat', true, <iobJS.StateCommon> {type: 'number', role: 'media.mode.repeat', name: 'REPEAT'});
|
||||
await createAliasAsync(id + '.DURATION', dpPath + '.Duration', true, <iobJS.StateCommon> {type: 'string', role: 'media.duration', name: 'DURATION'});
|
||||
await createAliasAsync(id + '.ELAPSED', dpPath + '.Time', true, <iobJS.StateCommon> {type: 'string', role: 'media.elapsed', name: 'ELAPSED'});
|
||||
} catch (err: any) {
|
||||
log('error at function createAutoMediaAlias Adapter Squeezebox: ' + err.message, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
break;
|
||||
case "bosesoundtouch.0.":
|
||||
@@ -5081,23 +5098,6 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
let vInstance = page.items[0].adapterPlayerInstance!;
|
||||
let v1Adapter = vInstance.split('.');
|
||||
let v2Adapter:NSPanel.PlayerType = v1Adapter[0] as NSPanel.PlayerType;
|
||||
|
||||
// Some magic to change the ID of the alias, since speakers are not a property but separate objects
|
||||
if(v2Adapter == 'squeezeboxrpc') {
|
||||
if(id && getObject(id).type != 'channel') {
|
||||
id = id + '.' + page.items[0].mediaDevice;
|
||||
page.items[0].id = id;
|
||||
page.heading = page.items[0].mediaDevice ?? '';
|
||||
} else {
|
||||
let idParts = id.split('.');
|
||||
if(idParts[idParts.length-1] !== page.items[0].mediaDevice) {
|
||||
idParts[idParts.length-1] = page.items[0].mediaDevice ?? '';
|
||||
id = idParts.join('.');
|
||||
page.items[0].id = id;
|
||||
page.heading = page.items[0].mediaDevice ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vMediaDevice = (page.items[0].mediaDevice != undefined) ? page.items[0].mediaDevice : '';
|
||||
|
||||
@@ -5105,7 +5105,7 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
if (!vMediaDevice) throw new Error(`Error in cardMedia! mediaDevice is empty! Page: ${JSON.stringify(page)}`);
|
||||
}
|
||||
createAutoMediaAlias(id, vMediaDevice, page.items[0].adapterPlayerInstance!);
|
||||
|
||||
|
||||
// Leave the display on if the alwaysOnDisplay parameter is specified (true)
|
||||
if (page.type == 'cardMedia' && pageCounter == 0 && page.items[0].alwaysOnDisplay != undefined) {
|
||||
out_msgs.push({ payload: 'pageType~cardMedia' });
|
||||
@@ -5122,6 +5122,8 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
subscribeMediaSubscriptionsAlexaAdd(page.items[0].id);
|
||||
} else if (v2Adapter == 'bosesoundtouch') {
|
||||
subscribeMediaSubscriptionsBoseAdd(page.items[0].id);
|
||||
} else if (v2Adapter == 'squeezeboxrpc') {
|
||||
subscribeMediaSubscriptionsSqueezeboxAdd(page.items[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5135,6 +5137,8 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
subscribeMediaSubscriptionsAlexaAdd(page.items[0].id);
|
||||
} else if (v2Adapter == 'bosesoundtouch') {
|
||||
subscribeMediaSubscriptionsBoseAdd(page.items[0].id);
|
||||
} else if (v2Adapter == 'squeezeboxrpc') {
|
||||
subscribeMediaSubscriptionsSqueezeboxAdd(page.items[0].id);
|
||||
}
|
||||
} else if (page.type == 'cardMedia' && pageCounter == -1) {
|
||||
//Do Nothing
|
||||
@@ -5197,7 +5201,33 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
title = title + ' (' + vElapsed + '|' + vDuration + ')';
|
||||
}
|
||||
}
|
||||
|
||||
let author = getState(id + '.ARTIST').val;
|
||||
|
||||
if (v2Adapter == 'squeezeboxrpc' && author.length == 0) {
|
||||
if(existsObject(([page.items[0].adapterPlayerInstance, 'Players.', page.items[0].mediaDevice, '.Playlist'].join('')))) {
|
||||
if(existsObject(([page.items[0].adapterPlayerInstance, 'Players.', page.items[0].mediaDevice, '.Playlist'].join('')))) {
|
||||
let lmstracklist = JSON.parse(getState(([page.items[0].adapterPlayerInstance, 'Players.', page.items[0].mediaDevice, '.Playlist'].join(''))).val);
|
||||
let currentIndex: number = parseInt(getState([page.items[0].adapterPlayerInstance, 'Players.', page.items[0].mediaDevice, '.PlaylistCurrentIndex'].join('')).val);
|
||||
author = lmstracklist[currentIndex].Artist + '|' + lmstracklist[currentIndex].Album;
|
||||
if (author.length > 37) {
|
||||
author = author.slice(0, 37) + '...';
|
||||
}
|
||||
let elapsedTime: number = parseInt(getState([page.items[0].adapterPlayerInstance, 'Players.', page.items[0].mediaDevice, '.Time'].join('')).val);
|
||||
let elapsedSeconds = elapsedTime%60 < 10 ? '0' : '';
|
||||
let vElapsed = Math.floor(elapsedTime/60) + ":" + elapsedSeconds + elapsedTime%60;
|
||||
|
||||
let durationSeconds = parseInt(lmstracklist[currentIndex].Duration)%60 < 10 ? '0' : '';
|
||||
let vDuration = Math.floor(parseInt(lmstracklist[currentIndex].Duration)/60) + ":" + durationSeconds + parseInt(lmstracklist[currentIndex].Duration)%60;
|
||||
title = lmstracklist[currentIndex].title;
|
||||
if (title.length > 25) {
|
||||
title = title.slice(0, 25) + '...';
|
||||
}
|
||||
title = title + ' (' + vElapsed + '|' + vDuration + ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let shuffle = getState(id + '.SHUFFLE').val;
|
||||
|
||||
//New Adapter/Player
|
||||
@@ -5480,8 +5510,8 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
|
||||
}, 2000);
|
||||
globalTracklist = page.items[0].globalTracklist;
|
||||
} else if(v2Adapter == 'squeezeboxrpc' && existsObject(([page.items[0].adapterPlayerInstance, '.Players.', page.items[0].mediaDevice, '.Playlist'].join('')))) {
|
||||
let lmstracklist = JSON.parse(getState(([page.items[0].adapterPlayerInstance, '.Players.', page.items[0].mediaDevice, '.Playlist'].join(''))).val);
|
||||
} else if(v2Adapter == 'squeezeboxrpc' && existsObject(([page.items[0].adapterPlayerInstance, 'Players.', page.items[0].mediaDevice, '.Playlist'].join('')))) {
|
||||
let lmstracklist = JSON.parse(getState(([page.items[0].adapterPlayerInstance, 'Players.', page.items[0].mediaDevice, '.Playlist'].join(''))).val);
|
||||
globalTracklist = lmstracklist;
|
||||
} else if(v2Adapter == 'sonos' && existsObject(([page.items[0].adapterPlayerInstance, 'root.', page.items[0].mediaDevice, '.playlist_set'].join('')))) {
|
||||
let lmstracklist = getState(([page.items[0].adapterPlayerInstance, 'root.', page.items[0].mediaDevice, '.queue'].join(''))).val;
|
||||
@@ -5576,9 +5606,11 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
repeatIcon = Icons.GetIcon('repeat');
|
||||
repeatIconCol = rgb_dec565(HMIOn);
|
||||
}
|
||||
/*
|
||||
else {
|
||||
repeatIcon = Icons.GetIcon('repeat-off');
|
||||
}
|
||||
*/
|
||||
} else if (v2Adapter == 'volumio') { /* Volumio: only Repeat true/false with API */
|
||||
if (getState(id + '.REPEAT').val == true) {
|
||||
repeatIcon = Icons.GetIcon('repeat-variant');
|
||||
@@ -5619,6 +5651,15 @@ function GenerateMediaPage(page: NSPanel.PageMedia): NSPanel.Payload[] {
|
||||
findLocale('media','crossfade') + '~' +
|
||||
'media5~'
|
||||
}
|
||||
} else if (v2Adapter == 'squeezeboxrpc') {
|
||||
if (page.items[0].crossfade == undefined || page.items[0].crossfade == false) {
|
||||
toolsString = 'input_sel' + '~' +
|
||||
id + '?seek' + '~' +
|
||||
media_icon + '~' +
|
||||
toolsIconCol + '~' +
|
||||
findLocale('media','seek') + '~' +
|
||||
'media5~'
|
||||
}
|
||||
} else {
|
||||
toolsString = 'button' + '~' +
|
||||
id + '' + '~' +
|
||||
@@ -6813,8 +6854,8 @@ function HandleButtonEvent(words: any): void {
|
||||
if (isPageMediaItem(pageItemTemp)) {
|
||||
let adaInstanceSplit = pageItemTemp.adapterPlayerInstance!.split('.');
|
||||
if (adaInstanceSplit[0] == 'squeezeboxrpc') {
|
||||
let adapterPlayerInstanceStateSeceltor: string = [pageItemTemp.adapterPlayerInstance, 'Players', pageItemTemp.mediaDevice, 'state'].join('.');
|
||||
if (Debug) log('HandleButtonEvent media-pause Squeezebox-> adapterPlayerInstanceStateSeceltor: ' + adapterPlayerInstanceStateSeceltor, 'info');
|
||||
let adapterPlayerInstanceStateSeceltor: string = pageItemTemp.adapterPlayerInstance + 'Players.' + pageItemTemp.mediaDevice + '.state';
|
||||
if (Debug) log('HandleButtonEvent media-pause Squeezebox-> adapterPlayerInstanceStateSeceltor: ' + adapterPlayerInstanceStateSeceltor, 'info');
|
||||
let stateVal = getState(adapterPlayerInstanceStateSeceltor).val;
|
||||
if (stateVal == 0) {
|
||||
setState(adapterPlayerInstanceStateSeceltor, 1);
|
||||
@@ -6877,6 +6918,13 @@ function HandleButtonEvent(words: any): void {
|
||||
setIfExists(id + '.SHUFFLE', false);
|
||||
}
|
||||
}
|
||||
if ((tempPage.adapterPlayerInstance).startsWith("squeezeboxrpc")) {
|
||||
if (getState(tempPage.adapterPlayerInstance + 'Players.' + tempPage.mediaDevice + '.PlaylistShuffle').val == 1) {
|
||||
setIfExists(tempPage.adapterPlayerInstance + 'Players.' + tempPage.mediaDevice + '.PlaylistShuffle', 0);
|
||||
} else {
|
||||
setIfExists(tempPage.adapterPlayerInstance + 'Players.' + tempPage.mediaDevice + '.PlaylistShuffle', 1);
|
||||
}
|
||||
}
|
||||
if ((tempPage.adapterPlayerInstance).startsWith("bosesoundtouch")) {
|
||||
if (Debug) log(tempPage.adapterPlayerInstance + 'nowPlaying.shuffle');
|
||||
if (getState(tempPage.adapterPlayerInstance + 'nowPlaying.shuffle').val == 'false') {
|
||||
@@ -6992,7 +7040,7 @@ function HandleButtonEvent(words: any): void {
|
||||
});
|
||||
break;
|
||||
case 'squeezeboxrpc':
|
||||
setState([pageItemPL.adapterPlayerInstance, 'Players', pageItemPL.mediaDevice, 'cmdPlayFavorite'].join('.'), words[4]);
|
||||
setState([adapterInstancePL, 'Players.', pageItemPL.mediaDevice, '.cmdPlayFavorite'].join(''), words[4]);
|
||||
break;
|
||||
case "bosesoundtouch":
|
||||
if (Debug) log('bosesoundtouch - playlist ' + pageItemPL.adapterPlayerInstance + ' - ' + words[4]);
|
||||
@@ -7048,7 +7096,7 @@ function HandleButtonEvent(words: any): void {
|
||||
break;
|
||||
case 'squeezeboxrpc':
|
||||
//@ts-ignore Fehler kommt von findPageItem in vscode
|
||||
setState([pageItemPL.adapterPlayerInstance, 'Players', pageItemPL.mediaDevice, 'PlaylistCurrentIndex'].join('.'), words[4]);
|
||||
setState([adapterInstanceTL, 'Players.', pageItemTL.mediaDevice, '.PlaylistCurrentIndex'].join(''), words[4]);
|
||||
break;
|
||||
case "bosesoundtouch":
|
||||
break;
|
||||
@@ -7102,6 +7150,13 @@ function HandleButtonEvent(words: any): void {
|
||||
switch (deviceAdapterSK) {
|
||||
case 'spotify-premium':
|
||||
break;
|
||||
case 'squeezeboxrpc':
|
||||
const vDuration: number = getState(adapterInstanceSK + 'Players.' + pageItemSeek.mediaDevice + '.Duration').val;
|
||||
const vSeekPercentage : number = words[4] * 10;
|
||||
const setSeekSeconds: number = vSeekPercentage * vDuration / 100;
|
||||
if (Debug) log(adapterInstanceSK + 'Players.' + pageItemSeek.mediaDevice + '.cmdGoTime' + ': ' + setSeekSeconds + ' sec.');
|
||||
setState(adapterInstanceSK + 'Players.' + pageItemSeek.mediaDevice + '.cmdGoTime', String(setSeekSeconds.toFixed(0)));
|
||||
break;
|
||||
case 'sonos':
|
||||
if (Debug) log('HandleButtonEvent mode-seek -> id: ' + id, 'info');
|
||||
setState(adapterInstanceSK + 'root.' + pageItemSeek.mediaDevice + '.seek', parseInt(words[4]) * 10);
|
||||
@@ -7160,7 +7215,7 @@ function HandleButtonEvent(words: any): void {
|
||||
if (!isPageMediaItem(pageItemTemp)) break;
|
||||
let adapterInstance = pageItemTemp.adapterPlayerInstance.split('.');
|
||||
if (adapterInstance[0] == 'squeezeboxrpc') {
|
||||
let adapterPlayerInstancePowerSelector: string = [pageItemTemp.adapterPlayerInstance, 'Players', pageItemTemp.mediaDevice, 'Power'].join('.');
|
||||
let adapterPlayerInstancePowerSelector: string = [pageItemTemp.adapterPlayerInstance, 'Players.', pageItemTemp.mediaDevice, '.Power'].join('');
|
||||
let stateVal = getState(adapterPlayerInstancePowerSelector).val;
|
||||
if (stateVal === 0) {
|
||||
setState(adapterPlayerInstancePowerSelector, 1);
|
||||
@@ -8186,9 +8241,16 @@ function GenerateDetailPage(type: NSPanel.PopupType, optional: NSPanel.mediaOpti
|
||||
const vTempAdapter = (pageItem.adapterPlayerInstance!).split('.');
|
||||
const vAdapter: NSPanel.PlayerType = vTempAdapter[0] as NSPanel.PlayerType;
|
||||
if (optional == 'seek') {
|
||||
const actualStateTemp: number = getState(pageItem.adapterPlayerInstance + 'root.' + pageItem.mediaDevice + '.seek').val;
|
||||
actualState = Math.round(actualStateTemp / 10) * 10 + '%';
|
||||
if (vAdapter == 'sonos') {
|
||||
const actualStateTemp: number = getState(pageItem.adapterPlayerInstance + 'root.' + pageItem.mediaDevice + '.seek').val;
|
||||
actualState = Math.round(actualStateTemp / 10) * 10 + '%';
|
||||
optionalString = '0%?10%?20%?30%?40%?50%?60%?70%?80%?90%?100%';
|
||||
}
|
||||
if (vAdapter == 'squeezeboxrpc') {
|
||||
const actualStateTime: number = parseInt(getState(pageItem.adapterPlayerInstance + 'Players.' + pageItem.mediaDevice + '.Time').val);
|
||||
const actualStateDuration: number = parseInt(getState(pageItem.adapterPlayerInstance + 'Players.' + pageItem.mediaDevice + '.Duration').val);
|
||||
const actualStateTemp: number = actualStateTime * 100 / actualStateDuration;
|
||||
actualState = Math.round(actualStateTemp / 10) * 10 + '%';
|
||||
optionalString = '0%?10%?20%?30%?40%?50%?60%?70%?80%?90%?100%';
|
||||
}
|
||||
mode = 'seek';
|
||||
|
||||
Reference in New Issue
Block a user