From df35496c6e1904a579f7cea36acdaf56f8057df2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 23 Feb 2022 03:43:49 -0800 Subject: [PATCH] Add media management dialog (#11787) Co-authored-by: Bram Kragten --- package.json | 1 + .../media-player/dialog-media-manage.ts | 337 ++++++++++++++++++ .../dialog-media-player-browse.ts | 24 +- .../media-player/ha-media-manage-button.ts | 69 ++++ .../media-player/ha-media-player-browse.ts | 5 + .../media-player/ha-media-upload-button.ts | 129 +++++++ .../media-player/show-media-manage-dialog.ts | 18 + src/data/media_source.ts | 9 + src/dialogs/generic/dialog-box.ts | 24 +- .../media-browser/ha-panel-media-browser.ts | 107 +----- src/panels/profile/ha-panel-profile.ts | 10 +- src/translations/en.json | 9 +- yarn.lock | 10 + 13 files changed, 638 insertions(+), 114 deletions(-) create mode 100644 src/components/media-player/dialog-media-manage.ts create mode 100644 src/components/media-player/ha-media-manage-button.ts create mode 100644 src/components/media-player/ha-media-upload-button.ts create mode 100644 src/components/media-player/show-media-manage-dialog.ts diff --git a/package.json b/package.json index 5067141225..5402e5d93d 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@fullcalendar/daygrid": "5.9.0", "@fullcalendar/interaction": "5.9.0", "@fullcalendar/list": "5.9.0", + "@lit-labs/motion": "^1.0.2", "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.7.0-pre.2#./.yarn/patches/@lit-labs/virtualizer/event-target-shim.patch", "@material/chips": "14.0.0-canary.261f2db59.0", "@material/data-table": "14.0.0-canary.261f2db59.0", diff --git a/src/components/media-player/dialog-media-manage.ts b/src/components/media-player/dialog-media-manage.ts new file mode 100644 index 0000000000..1bd20ca667 --- /dev/null +++ b/src/components/media-player/dialog-media-manage.ts @@ -0,0 +1,337 @@ +import { animate } from "@lit-labs/motion"; +import "@material/mwc-list/mwc-check-list-item"; +import "@material/mwc-list/mwc-list-item"; +import "@material/mwc-list/mwc-list"; +import { repeat } from "lit/directives/repeat"; +import { mdiClose, mdiDelete } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { computeRTLDirection } from "../../common/util/compute_rtl"; +import { haStyleDialog } from "../../resources/styles"; +import type { HomeAssistant } from "../../types"; +import "../ha-header-bar"; +import "../ha-dialog"; +import "../ha-svg-icon"; +import "../ha-circular-progress"; +import "./ha-media-player-browse"; +import "./ha-media-upload-button"; +import type { MediaManageDialogParams } from "./show-media-manage-dialog"; +import { + MediaClassBrowserSettings, + MediaPlayerItem, +} from "../../data/media-player"; +import { + browseLocalMediaPlayer, + removeLocalMedia, +} from "../../data/media_source"; +import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; + +@customElement("dialog-media-manage") +class DialogMediaManage extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _currentItem?: MediaPlayerItem; + + @state() private _params?: MediaManageDialogParams; + + @state() private _uploading = false; + + @state() private _deleting = false; + + @state() private _selected = new Set(); + + private _filesChanged = false; + + public showDialog(params: MediaManageDialogParams): void { + this._params = params; + this._refreshMedia(); + } + + public closeDialog() { + if (this._filesChanged && this._params!.onClose) { + this._params!.onClose(); + } + this._params = undefined; + this._currentItem = undefined; + this._uploading = false; + this._deleting = false; + this._filesChanged = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render(): TemplateResult { + if (!this._params) { + return html``; + } + + const children = + this._currentItem?.children?.filter((child) => !child.can_expand) || []; + + let fileIndex = 0; + + return html` + + + ${this._selected.size === 0 + ? html` + + ${this.hass.localize( + "ui.components.media-browser.file_management.title" + )} + + + + ${this._uploading + ? "" + : html` + + `} + ` + : html` + + + + + ${this._deleting + ? "" + : html` + + + + `} + `} + + ${!this._currentItem + ? html` +
+ +
+ ` + : !children.length + ? html`
+

+ ${this.hass.localize( + "ui.components.media-browser.file_management.no_items" + )} +

+ ${this._currentItem?.children?.length + ? html`${this.hass.localize( + "ui.components.media-browser.file_management.folders_not_supported" + )}` + : ""} +
` + : html` + + ${repeat( + children, + (item) => item.media_content_id, + (item) => { + const icon = html` + + `; + return html` + + ${icon} ${item.title} + + `; + } + )} + + `} +
+ `; + } + + private _handleSelected(ev) { + this._selected = ev.detail.index; + } + + private _startUploading() { + this._uploading = true; + this._filesChanged = true; + } + + private _doneUploading() { + this._uploading = false; + this._refreshMedia(); + } + + private _handleDeselectAll() { + if (this._selected.size) { + this._selected = new Set(); + } + } + + private async _handleDelete() { + if ( + !(await showConfirmationDialog(this, { + text: this.hass.localize( + "ui.components.media-browser.file_management.confirm_delete", + { count: this._selected.size } + ), + warning: true, + })) + ) { + return; + } + this._filesChanged = true; + this._deleting = true; + + const toDelete: MediaPlayerItem[] = []; + let fileIndex = 0; + this._currentItem!.children!.forEach((item) => { + if (item.can_expand) { + return; + } + if (this._selected.has(fileIndex++)) { + toDelete.push(item); + } + }); + + try { + await Promise.all( + toDelete.map(async (item) => { + await removeLocalMedia(this.hass, item.media_content_id); + this._currentItem = { + ...this._currentItem!, + children: this._currentItem!.children!.filter((i) => i !== item), + }; + }) + ); + } finally { + this._deleting = false; + this._selected = new Set(); + } + } + + private async _refreshMedia() { + this._selected = new Set(); + this._currentItem = undefined; + this._currentItem = await browseLocalMediaPlayer( + this.hass, + this._params!.currentItem.media_content_id + ); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-dialog { + --dialog-z-index: 8; + --dialog-content-padding: 0; + } + + @media (min-width: 800px) { + ha-dialog { + --mdc-dialog-max-width: 800px; + --dialog-surface-position: fixed; + --dialog-surface-top: 40px; + --mdc-dialog-max-height: calc(100vh - 72px); + } + } + + ha-header-bar { + --mdc-theme-on-primary: var(--primary-text-color); + --mdc-theme-primary: var(--mdc-theme-surface); + flex-shrink: 0; + border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); + } + + ha-media-upload-button, + mwc-button { + --mdc-theme-primary: var(--mdc-theme-on-primary); + } + + .danger { + --mdc-theme-primary: var(--error-color); + } + + ha-svg-icon[slot="icon"] { + vertical-align: middle; + } + + .refresh { + display: flex; + height: 200px; + justify-content: center; + align-items: center; + } + + .no-items { + text-align: center; + padding: 16px; + } + .folders { + color: var(--secondary-text-color); + font-style: italic; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-media-manage": DialogMediaManage; + } +} diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts index 7287d7aba2..01f7efbbd8 100644 --- a/src/components/media-player/dialog-media-player-browse.ts +++ b/src/components/media-player/dialog-media-player-browse.ts @@ -1,7 +1,7 @@ import "../ha-header-bar"; import { mdiArrowLeft, mdiClose } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event"; import { computeRTLDirection } from "../../common/util/compute_rtl"; import type { @@ -13,7 +13,11 @@ import { haStyleDialog } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; import "../ha-dialog"; import "./ha-media-player-browse"; -import type { MediaPlayerItemId } from "./ha-media-player-browse"; +import "./ha-media-manage-button"; +import type { + HaMediaPlayerBrowse, + MediaPlayerItemId, +} from "./ha-media-player-browse"; import { MediaPlayerBrowseDialogParams } from "./show-media-browser-dialog"; @customElement("dialog-media-player-browse") @@ -26,6 +30,8 @@ class DialogMediaPlayerBrowse extends LitElement { @state() private _params?: MediaPlayerBrowseDialogParams; + @query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse; + public showDialog(params: MediaPlayerBrowseDialogParams): void { this._params = params; this._navigateIds = params.navigateIds || [ @@ -80,6 +86,12 @@ class DialogMediaPlayerBrowse extends LitElement { : this._currentItem.title} + + + + `; + } + + private _manage() { + showMediaManageDialog(this, { + currentItem: this.currentItem!, + onClose: () => fireEvent(this, "media-refresh"), + }); + } + + static styles = css` + mwc-button { + /* We use icon + text to show disabled state */ + --mdc-button-disabled-ink-color: --mdc-theme-primary; + } + + ha-svg-icon[slot="icon"], + ha-circular-progress[slot="icon"] { + vertical-align: middle; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-media-manage-button": MediaManageButton; + } +} diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index 1895c78608..882673175e 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -131,6 +131,11 @@ export class HaMediaPlayerBrowse extends LitElement { currentId.media_content_id, currentId.media_content_type ); + // Update the parent with latest item. + fireEvent(this, "media-browsed", { + ids: this.navigateIds, + current: this._currentItem, + }); } catch (err) { this._setError(err); } diff --git a/src/components/media-player/ha-media-upload-button.ts b/src/components/media-player/ha-media-upload-button.ts new file mode 100644 index 0000000000..65d7b36982 --- /dev/null +++ b/src/components/media-player/ha-media-upload-button.ts @@ -0,0 +1,129 @@ +import { mdiUpload } from "@mdi/js"; +import "@material/mwc-button"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { MediaPlayerItem } from "../../data/media-player"; +import "../ha-circular-progress"; +import "../ha-svg-icon"; +import { + isLocalMediaSourceContentId, + uploadLocalMedia, +} from "../../data/media_source"; +import type { HomeAssistant } from "../../types"; +import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; + +declare global { + interface HASSDomEvents { + uploading: unknown; + "media-refresh": unknown; + } +} + +@customElement("ha-media-upload-button") +class MediaUploadButton extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() currentItem?: MediaPlayerItem; + + @state() _uploading = 0; + + protected render(): TemplateResult { + if ( + !this.currentItem || + !isLocalMediaSourceContentId(this.currentItem.media_content_id || "") + ) { + return html``; + } + return html` + 0 + ? this.hass.localize( + "ui.components.media-browser.file_management.uploading", + { + count: this._uploading, + } + ) + : this.hass.localize( + "ui.components.media-browser.file_management.add_media" + )} + .disabled=${this._uploading > 0} + @click=${this._startUpload} + > + ${this._uploading > 0 + ? html` + + ` + : html` `} + + `; + } + + private async _startUpload() { + if (this._uploading > 0) { + return; + } + const input = document.createElement("input"); + input.type = "file"; + input.accept = "audio/*,video/*,image/*"; + input.multiple = true; + input.addEventListener( + "change", + async () => { + fireEvent(this, "uploading"); + const files = input.files!; + document.body.removeChild(input); + const target = this.currentItem!.media_content_id!; + + for (let i = 0; i < files.length; i++) { + this._uploading = files.length - i; + + try { + // eslint-disable-next-line no-await-in-loop + await uploadLocalMedia(this.hass, target, files[i]); + } catch (err: any) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.components.media-browser.file_management.upload_failed", + { + reason: err.message || err, + } + ), + }); + break; + } + } + this._uploading = 0; + fireEvent(this, "media-refresh"); + }, + { once: true } + ); + // https://stackoverflow.com/questions/47664777/javascript-file-input-onchange-not-working-ios-safari-only + input.style.display = "none"; + document.body.append(input); + input.click(); + } + + static styles = css` + mwc-button { + /* We use icon + text to show disabled state */ + --mdc-button-disabled-ink-color: --mdc-theme-primary; + } + + ha-svg-icon[slot="icon"], + ha-circular-progress[slot="icon"] { + vertical-align: middle; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-media-upload-button": MediaUploadButton; + } +} diff --git a/src/components/media-player/show-media-manage-dialog.ts b/src/components/media-player/show-media-manage-dialog.ts new file mode 100644 index 0000000000..efb2c08c2d --- /dev/null +++ b/src/components/media-player/show-media-manage-dialog.ts @@ -0,0 +1,18 @@ +import { fireEvent } from "../../common/dom/fire_event"; +import { MediaPlayerItem } from "../../data/media-player"; + +export interface MediaManageDialogParams { + currentItem: MediaPlayerItem; + onClose?: () => void; +} + +export const showMediaManageDialog = ( + element: HTMLElement, + dialogParams: MediaManageDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-media-manage", + dialogImport: () => import("./dialog-media-manage"), + dialogParams, + }); +}; diff --git a/src/data/media_source.ts b/src/data/media_source.ts index dfcdbb37ab..61494ee3f8 100644 --- a/src/data/media_source.ts +++ b/src/data/media_source.ts @@ -49,3 +49,12 @@ export const uploadLocalMedia = async ( } return resp.json(); }; + +export const removeLocalMedia = async ( + hass: HomeAssistant, + media_content_id: string +) => + hass.callWS({ + type: "media_source/local_source/remove", + media_content_id, + }); diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index 2ca11e4ac5..df13ffaf4b 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -1,9 +1,10 @@ import "@material/mwc-button/mwc-button"; +import { mdiAlertOutline } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-dialog"; +import "../../components/ha-svg-icon"; import "../../components/ha-switch"; import "../../components/ha-textfield"; import { haStyleDialog } from "../../resources/styles"; @@ -50,20 +51,22 @@ class DialogBox extends LitElement { ?escapeKeyAction=${confirmPrompt} @closed=${this._dialogClosed} defaultAction="ignore" - .heading=${this._params.title + .heading=${html`${this._params.warning + ? html` ` + : ""}${this._params.title ? this._params.title : this._params.confirmation && - this.hass.localize("ui.dialogs.generic.default_confirmation_title")} + this.hass.localize( + "ui.dialogs.generic.default_confirmation_title" + )}`} >
${this._params.text ? html` -

+

${this._params.text}

` @@ -172,9 +175,6 @@ class DialogBox extends LitElement { /* Place above other dialogs */ --dialog-z-index: 104; } - .warning { - color: var(--warning-color); - } `, ]; } diff --git a/src/panels/media-browser/ha-panel-media-browser.ts b/src/panels/media-browser/ha-panel-media-browser.ts index 9bf6df9990..8d21a8192a 100644 --- a/src/panels/media-browser/ha-panel-media-browser.ts +++ b/src/panels/media-browser/ha-panel-media-browser.ts @@ -1,4 +1,4 @@ -import { mdiArrowLeft, mdiUpload } from "@mdi/js"; +import { mdiArrowLeft } from "@mdi/js"; import "@polymer/app-layout/app-header/app-header"; import "@polymer/app-layout/app-toolbar/app-toolbar"; import "@material/mwc-button"; @@ -15,10 +15,9 @@ import { LocalStorage } from "../../common/decorators/local-storage"; import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event"; import { navigate } from "../../common/navigate"; import "../../components/ha-menu-button"; -import "../../components/ha-circular-progress"; import "../../components/ha-icon-button"; -import "../../components/ha-svg-icon"; import "../../components/media-player/ha-media-player-browse"; +import "../../components/media-player/ha-media-manage-button"; import type { HaMediaPlayerBrowse, MediaPlayerItemId, @@ -28,11 +27,7 @@ import { MediaPickedEvent, MediaPlayerItem, } from "../../data/media-player"; -import { - isLocalMediaSourceContentId, - resolveMediaSource, - uploadLocalMedia, -} from "../../data/media_source"; +import { resolveMediaSource } from "../../data/media_source"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant, Route } from "../../types"; @@ -66,8 +61,6 @@ class PanelMediaBrowser extends LitElement { @state() _currentItem?: MediaPlayerItem; - @state() _uploading = 0; - private _navigateIds: MediaPlayerItemId[] = [ { media_content_id: undefined, @@ -107,43 +100,11 @@ class PanelMediaBrowser extends LitElement { ) : this._currentItem.title}
- ${this._currentItem && - isLocalMediaSourceContentId( - this._currentItem.media_content_id || "" - ) - ? html` - 0 - ? this.hass.localize( - "ui.components.media-browser.file_management.uploading", - { - count: this._uploading, - } - ) - : this.hass.localize( - "ui.components.media-browser.file_management.add_media" - )} - .disabled=${this._uploading > 0} - @click=${this._startUpload} - > - ${this._uploading > 0 - ? html` - - ` - : html` - - `} - - ` - : ""} + 0) { - return; - } - const input = document.createElement("input"); - input.type = "file"; - input.accept = "audio/*,video/*,image/*"; - input.multiple = true; - input.addEventListener( - "change", - async () => { - const files = input.files!; - document.body.removeChild(input); - const target = this._currentItem!.media_content_id!; - - for (let i = 0; i < files.length; i++) { - this._uploading = files.length - i; - try { - // eslint-disable-next-line no-await-in-loop - await uploadLocalMedia(this.hass, target, files[i]); - } catch (err: any) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.components.media-browser.file_management.upload_failed", - { - reason: err.message || err, - } - ), - }); - break; - } - } - this._uploading = 0; - await this._browser.refresh(); - }, - { once: true } - ); - // https://stackoverflow.com/questions/47664777/javascript-file-input-onchange-not-working-ios-safari-only - input.style.display = "none"; - document.body.append(input); - input.click(); + private _refreshMedia() { + this._browser.refresh(); } static get styles(): CSSResultGroup { return [ haStyle, css` - app-toolbar mwc-button { + app-toolbar { --mdc-theme-primary: var(--app-header-text-color); - /* We use icon + text to show disabled state */ - --mdc-button-disabled-ink-color: var(--app-header-text-color); } ha-media-player-browse { @@ -357,11 +277,6 @@ class PanelMediaBrowser extends LitElement { left: 0; right: 0; } - - ha-svg-icon[slot="icon"], - ha-circular-progress[slot="icon"] { - vertical-align: middle; - } `, ]; } diff --git a/src/panels/profile/ha-panel-profile.ts b/src/panels/profile/ha-panel-profile.ts index a1c7b66ad3..7ecc99b68f 100644 --- a/src/panels/profile/ha-panel-profile.ts +++ b/src/panels/profile/ha-panel-profile.ts @@ -3,7 +3,7 @@ import "@polymer/app-layout/app-header/app-header"; import "@polymer/app-layout/app-toolbar/app-toolbar"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { property, state } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-card"; import "../../components/ha-menu-button"; @@ -33,6 +33,7 @@ import "./ha-refresh-tokens-card"; import "./ha-set-suspend-row"; import "./ha-set-vibrate-row"; +@customElement("ha-panel-profile") class HaPanelProfile extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -252,5 +253,8 @@ class HaPanelProfile extends LitElement { ]; } } - -customElements.define("ha-panel-profile", HaPanelProfile); +declare global { + interface HTMLElementTagNameMap { + "ha-panel-profile": HaPanelProfile; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 250b37799d..54eda22ef1 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -549,10 +549,17 @@ "no_media_folder": "It looks like you have not yet created a media directory.", "setup_local_help": "Check the {documentation} on how to setup local media.", "file_management": { + "title": "Media Management", + "manage": "Manage", + "no_items": "No media items found", + "folders_not_supported": "Folders can not be managed via the UI.", "highlight_button": "Click here to upload your first media", "upload_failed": "Upload failed: {reason}", "add_media": "Add Media", - "uploading": "Uploading {count} {count, plural,\n one {file}\n other {files}\n}" + "uploading": "Uploading {count} {count, plural,\n one {file}\n other {files}\n}", + "confirm_delete": "Do you want to delete {count} {count, plural,\n one {file}\n other {files}\n}?", + "delete": "Delete {count}", + "deleting": "Deleting {count}" }, "class": { "album": "Album", diff --git a/yarn.lock b/yarn.lock index 469c9c90b1..e642cb2d89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1960,6 +1960,15 @@ __metadata: languageName: node linkType: hard +"@lit-labs/motion@npm:^1.0.2": + version: 1.0.2 + resolution: "@lit-labs/motion@npm:1.0.2" + dependencies: + lit: ^2.0.0 + checksum: 598e0be22a3f931ec971fa001e863c5a4dd82f572d8d0214211bde1d6403b00e3c8fafa92f30b0c02b7272bc12510ec40060bf2c8ab18151bdb264cf32f0ef71 + languageName: node + linkType: hard + "@lit-labs/virtualizer@0.7.0-pre.2": version: 0.7.0-pre.2 resolution: "@lit-labs/virtualizer@npm:0.7.0-pre.2" @@ -9131,6 +9140,7 @@ fsevents@^1.2.7: "@fullcalendar/interaction": 5.9.0 "@fullcalendar/list": 5.9.0 "@koa/cors": ^3.1.0 + "@lit-labs/motion": ^1.0.2 "@lit-labs/virtualizer": "patch:@lit-labs/virtualizer@0.7.0-pre.2#./.yarn/patches/@lit-labs/virtualizer/event-target-shim.patch" "@material/chips": 14.0.0-canary.261f2db59.0 "@material/data-table": 14.0.0-canary.261f2db59.0