Panel fixes: tall card row_span, room tab styling, vacuum card image+value, AC airflow labels, AC fan speed sections (auto 3-btn + manual 5-step slider), light subtitle fallback, curtain pane dividers, dimmable light presets, LED panel brightness + reorder. New assets: 34 ui-icons (climate modes, cover, effects, battery, led-strip, switch states), 3 logos, 90 app surface PNGs, design system specs (components, ux-mechanics, tokens). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
171 lines
8.7 KiB
JavaScript
171 lines
8.7 KiB
JavaScript
const LUMA_MEDIA_VERSION = '0.2.0';
|
|
|
|
class LumaMediaCard extends HTMLElement {
|
|
set hass(hass) { this._hass = hass; this._render(); }
|
|
|
|
setConfig(config) {
|
|
if (!config.entity) throw new Error('Entity is required');
|
|
this._config = { entity: config.entity, name: config.name || '', size: config.size || '1x2',
|
|
accent_color: config.accent_color || '', icon: config.icon || '',
|
|
tap_action: config.tap_action || { action: 'more-info' },
|
|
hold_action: config.hold_action || { action: 'more-info' }, ...config };
|
|
this._render();
|
|
}
|
|
|
|
getCardSize() { return 4; }
|
|
static getStubConfig() { return { entity: '', name: '', size: '1x2' }; }
|
|
connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); this._render(); }
|
|
|
|
_getState() { return this._hass?.states?.[this._config?.entity]; }
|
|
|
|
_isActive() {
|
|
const s = this._getState();
|
|
return s && (s.state === 'playing' || s.state === 'paused');
|
|
}
|
|
|
|
_callService(service, data = {}) {
|
|
if (!this._hass || !this._config) return;
|
|
this._hass.callService('media_player', service, { entity_id: this._config.entity, ...data });
|
|
}
|
|
|
|
_handleClick() {
|
|
if (!this._hass || !this._config) return;
|
|
const ev = new Event('hass-more-info', { composed: true });
|
|
ev.detail = { entityId: this._config.entity };
|
|
this.dispatchEvent(ev);
|
|
}
|
|
|
|
_formatTime(secs) {
|
|
if (!secs || secs <= 0) return '0:00';
|
|
const m = Math.floor(secs / 60);
|
|
const s = Math.floor(secs % 60);
|
|
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
|
}
|
|
|
|
_render() {
|
|
if (!this.shadowRoot || !this._config) return;
|
|
const s = this._getState();
|
|
const isPlaying = s?.state === 'playing';
|
|
const isPaused = s?.state === 'paused';
|
|
const isActive = isPlaying || isPaused;
|
|
const hasMedia = s?.attributes?.media_title ? true : false;
|
|
const title = s?.attributes?.media_title || this._config.name || s?.attributes?.friendly_name || '';
|
|
const artist = s?.attributes?.media_artist || '';
|
|
const entityPicture = s?.attributes?.entity_picture || '';
|
|
const volume = Math.round((s?.attributes?.volume_level || 0) * 100);
|
|
const duration = s?.attributes?.media_duration || 0;
|
|
const position = s?.attributes?.media_position || 0;
|
|
const progress = duration > 0 ? Math.min(position / duration, 1) : 0;
|
|
|
|
const artBg = entityPicture
|
|
? `background-image: url('${entityPicture}'); background-size: cover; background-position: center;`
|
|
: `background: rgba(36,40,48,0.9); display: flex; align-items: center; justify-content: center;`;
|
|
|
|
const volFillH = volume > 0 ? `height: ${volume}%` : 'height: 0';
|
|
const volAlpha = Math.min(volume / 100 * 0.35, 0.30).toFixed(2);
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host { display: block; }
|
|
.card {
|
|
position: relative; overflow: hidden; background: rgba(36,40,48,0.75);
|
|
border: 1px solid rgba(255,255,255,0.10); border-radius: 22px;
|
|
height: 100%; min-height: 280px; display: flex; flex-direction: column;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
|
transition: border-color .15s, box-shadow .15s;
|
|
}
|
|
.card:hover { border-color: rgba(255,255,255,0.18); box-shadow: 0 8px 28px rgba(0,0,0,.28); }
|
|
.card.active { border-color: rgba(155,125,219,0.25); }
|
|
|
|
.vol-fill {
|
|
position: absolute; left: 0; bottom: 0; width: 100%; ${volFillH};
|
|
background: linear-gradient(to top, rgba(112,80,176,${volAlpha}), transparent);
|
|
border-radius: 22px; pointer-events: none; transition: height .3s;
|
|
}
|
|
|
|
.top { display: flex; gap: 16px; padding: 16px; position: relative; z-index: 1; flex: 1; min-height: 0; }
|
|
|
|
.art {
|
|
width: 180px; height: 180px; border-radius: 12px; flex-shrink: 0;
|
|
${artBg}
|
|
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
|
}
|
|
.art ha-icon { --mdc-icon-size: 56px; color: ${isActive ? 'rgba(180,142,250,0.7)' : 'rgba(187,187,187,0.3)'}; }
|
|
|
|
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; padding-top: 8px; }
|
|
.title { font-size: 17px; font-weight: 600; letter-spacing: -0.02em; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.artist { font-size: 14px; font-weight: 400; color: rgba(232,232,232,0.55); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.vol-row { display: flex; align-items: center; gap: 4px; margin-top: 10px; }
|
|
.vol-row ha-icon { --mdc-icon-size: 14px; color: rgba(255,255,255,0.4); }
|
|
.vol-pct { font-size: 13px; font-weight: 500; color: rgba(232,232,232,0.4); }
|
|
|
|
.progress { padding: 0 16px; position: relative; z-index: 1; }
|
|
.bar-track { height: 3px; border-radius: 1.5px; background: rgba(255,255,255,0.15); }
|
|
.bar-played { height: 3px; border-radius: 1.5px; background: rgba(255,255,255,0.70); width: ${(progress * 100).toFixed(1)}%; transition: width .5s linear; }
|
|
.time-row { display: flex; justify-content: space-between; margin-top: 4px; }
|
|
.time { font-size: 11px; font-weight: 400; letter-spacing: 0.02em; color: rgba(255,255,255,0.45); }
|
|
|
|
.controls { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px 14px; position: relative; z-index: 1; }
|
|
.ctrl-btn { background: none; border: none; cursor: pointer; padding: 6px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background .15s; }
|
|
.ctrl-btn:hover { background: rgba(255,255,255,0.08); }
|
|
.ctrl-btn ha-icon { --mdc-icon-size: 22px; color: rgba(255,255,255,0.45); }
|
|
.ctrl-btn.play ha-icon { --mdc-icon-size: 30px; color: #fff; }
|
|
.ctrl-btn.edge ha-icon { --mdc-icon-size: 22px; color: rgba(255,255,255,0.35); }
|
|
</style>
|
|
<div class="card ${isActive ? 'active' : ''}">
|
|
<div class="vol-fill"></div>
|
|
<div class="top">
|
|
<div class="art">${!entityPicture ? '<ha-icon icon="mdi:music"></ha-icon>' : ''}</div>
|
|
<div class="info">
|
|
<div class="title">${title}</div>
|
|
${artist ? `<div class="artist">${artist}</div>` : ''}
|
|
<div class="vol-row">
|
|
<ha-icon icon="mdi:volume-high"></ha-icon>
|
|
<span class="vol-pct">${volume}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${duration > 0 ? `
|
|
<div class="progress">
|
|
<div class="bar-track"><div class="bar-played"></div></div>
|
|
<div class="time-row">
|
|
<span class="time">${this._formatTime(position)}</span>
|
|
<span class="time">-${this._formatTime(duration - position)}</span>
|
|
</div>
|
|
</div>` : ''}
|
|
<div class="controls">
|
|
<button class="ctrl-btn edge" data-action="playlist"><ha-icon icon="mdi:playlist-music"></ha-icon></button>
|
|
<button class="ctrl-btn" data-action="prev"><ha-icon icon="mdi:skip-previous"></ha-icon></button>
|
|
<button class="ctrl-btn" data-action="seek-back"><ha-icon icon="mdi:rewind-10"></ha-icon></button>
|
|
<button class="ctrl-btn play" data-action="play">
|
|
<ha-icon icon="${isPlaying ? 'mdi:pause' : 'mdi:play'}"></ha-icon>
|
|
</button>
|
|
<button class="ctrl-btn" data-action="seek-fwd"><ha-icon icon="mdi:fast-forward-10"></ha-icon></button>
|
|
<button class="ctrl-btn" data-action="next"><ha-icon icon="mdi:skip-next"></ha-icon></button>
|
|
<button class="ctrl-btn edge" data-action="search"><ha-icon icon="mdi:magnify"></ha-icon></button>
|
|
</div>
|
|
</div>`;
|
|
|
|
this.shadowRoot.querySelector('.top').addEventListener('click', () => this._handleClick());
|
|
|
|
this.shadowRoot.querySelectorAll('.ctrl-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
switch (btn.dataset.action) {
|
|
case 'play': this._callService(isPlaying ? 'media_pause' : 'media_play'); break;
|
|
case 'prev': this._callService('media_previous_track'); break;
|
|
case 'next': this._callService('media_next_track'); break;
|
|
case 'seek-back': if (duration > 0) this._callService('media_seek', { seek_position: Math.max(0, position - 10) }); break;
|
|
case 'seek-fwd': if (duration > 0) this._callService('media_seek', { seek_position: Math.min(duration, position + 10) }); break;
|
|
default: this._handleClick();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define('luma-media-card', LumaMediaCard);
|
|
window.customCards = window.customCards || [];
|
|
window.customCards.push({ type: 'luma-media-card', name: 'Luma Media', description: 'Luma-style media card with album art, progress bar, and 7-button transport.', preview: true });
|
|
console.info(`%c LUMA-MEDIA-CARD %c v${LUMA_MEDIA_VERSION} `, 'background:#9b7ddb;color:#111;font-weight:bold', 'background:#161a22;color:#9b7ddb');
|