diff --git a/custom_components/mass/const.py b/custom_components/mass/const.py index 2477a5d7..265a6e7f 100644 --- a/custom_components/mass/const.py +++ b/custom_components/mass/const.py @@ -51,5 +51,6 @@ KODI_DOMAIN = "kodi" GROUP_DOMAIN = "group" ALEXA_DOMAIN = "alexa_media" +VOLUMIO_DOMAIN = "volumio" BLACKLIST_DOMAINS = (ATV_DOMAIN, ALEXA_DOMAIN) diff --git a/custom_components/mass/frontend/src/components/GlobalSearch.vue b/custom_components/mass/frontend/src/components/GlobalSearch.vue new file mode 100644 index 00000000..83cc6aef --- /dev/null +++ b/custom_components/mass/frontend/src/components/GlobalSearch.vue @@ -0,0 +1,279 @@ + + + diff --git a/custom_components/mass/frontend/src/components/InfoHeader.vue b/custom_components/mass/frontend/src/components/InfoHeader.vue index 90bdbe23..65d1c5fc 100644 --- a/custom_components/mass/frontend/src/components/InfoHeader.vue +++ b/custom_components/mass/frontend/src/components/InfoHeader.vue @@ -124,7 +124,7 @@ color="primary" v-bind="props" :prepend-icon="mdiPlayCircle" - :disabled="!activePlayerQueue" + :disabled="!store.selectedPlayer?.available" > {{ $t("play") }} diff --git a/custom_components/mass/frontend/src/components/ItemsListing.vue b/custom_components/mass/frontend/src/components/ItemsListing.vue index 21cef397..8d9d316e 100644 --- a/custom_components/mass/frontend/src/components/ItemsListing.vue +++ b/custom_components/mass/frontend/src/components/ItemsListing.vue @@ -47,6 +47,19 @@ {{ $t("tooltip.filter_library") }} + + + {{ $t("tooltip.album_artist_filter") }} + + - {{ $t("tooltip.refresh_new_content") }} + {{ + $t("tooltip.refresh_new_content") + }} {{ $t("tooltip.refresh") }} @@ -69,7 +84,10 @@ {{ $t("tooltip.sort_options") }} @@ -79,7 +97,9 @@
- + @@ -125,7 +145,12 @@ :label="$t('search')" hide-details variant="filled" - style="width: auto; margin-left: 15px; margin-right: 15px; margin-top: 10px" + style=" + width: auto; + margin-left: 15px; + margin-right: 15px; + margin-top: 10px; + " v-if="showSearch" @focus="searchHasFocus = true" @blur="searchHasFocus = false" @@ -144,7 +169,12 @@ - + {{ $t("no_content_filter") }}{{ $t("no_content_filter") + }}{{ $t("try_global_search") }} {{ $t("no_content") }}
+ + {{ $t("items_selected", [selectedItems.length]) }} + + @@ -215,7 +260,8 @@ import { mdiRefresh, mdiCheckboxMultipleOutline, mdiCheckboxMultipleBlankOutline, - mdiTune, + mdiAccountMusic, + mdiAccountMusicOutline, } from "@mdi/js"; import { @@ -259,6 +305,7 @@ export interface Props { showLibrary?: boolean; showDuration?: boolean; parentItem?: MediaItemType; + showAlbumArtistsOnlyFilter?: boolean; loadData: ( offset: number, limit: number, @@ -299,12 +346,17 @@ const selectedItems = ref([]); const showContextMenu = ref(false); const newContentAvailable = ref(false); const showCheckboxes = ref(false); +const albumArtistsOnlyFilter = ref(false); // computed properties const thumbSize = computed(() => { return mobile.value ? 140 : 150; }); +const emit = defineEmits<{ + (e: "toggleAlbumArtistsOnly", value: boolean): void; +}>(); + // methods const toggleSearch = function () { if (showSearch.value) showSearch.value = false; @@ -329,6 +381,17 @@ const toggleLibraryFilter = function () { loadData(true); }; +const toggleAlbumArtistsFilter = function () { + albumArtistsOnlyFilter.value = !albumArtistsOnlyFilter.value; + const albumArtistsOnlyStr = albumArtistsOnlyFilter.value ? "true" : "false"; + localStorage.setItem( + `albumArtistsFilter.${props.itemtype}`, + albumArtistsOnlyStr + ); + emit("toggleAlbumArtistsOnly", albumArtistsOnlyFilter.value); + loadData(true); +}; + const isSelected = function (item: MediaItemType) { return selectedItems.value.includes(item); }; @@ -373,7 +436,8 @@ const onClick = function (mediaItem: MediaItemType) { if ( ["artist", "album", "playlist"].includes(mediaItem.media_type) || - force_provider_version == "true" + force_provider_version == "true" || + !store.selectedPlayer?.available ) { router.push({ name: mediaItem.media_type, @@ -413,10 +477,16 @@ const loadNextPage = function ($state: any) { }); }; +const redirectSearch = function () { + localStorage.setItem("globalsearch", search.value); + router.push({ name: "home" }); +}; + // watchers watch( () => search.value, (newVal) => { + if (newVal) showSearch.value = true; loadData(true); } ); @@ -442,6 +512,9 @@ const loadData = async function (clear = false, limit = defaultLimit) { } totalItems.value = nextItems.total; loading.value = false; + let storKey = `search.${props.itemtype}`; + if (props.parentItem) storKey += props.parentItem.item_id; + localStorage.setItem(storKey, search.value); }; // get/set default settings at load @@ -474,6 +547,24 @@ onMounted(() => { inLibraryOnly.value = true; } } + + // get stored/default albumArtistsOnlyFilter for this itemtype + if (props.showAlbumArtistsOnlyFilter !== false) { + const albumArtistsOnlyStr = localStorage.getItem( + `albumArtistsFilter.${props.itemtype}` + ); + if (albumArtistsOnlyStr && albumArtistsOnlyStr == "true") { + albumArtistsOnlyFilter.value = true; + emit("toggleAlbumArtistsOnly", albumArtistsOnlyFilter.value); + } + } + // get stored searchquery + let storKey = `search.${props.itemtype}`; + if (props.parentItem) storKey += props.parentItem.item_id; + const savedSearch = localStorage.getItem(storKey); + if (savedSearch) { + search.value = savedSearch; + } loadData(true); }); @@ -486,25 +577,11 @@ onMounted(() => { MassEventType.MEDIA_ITEM_DELETED, ], (evt: MassEvent) => { - if (props.parentItem) { - // update details listing if parent updates - const updatedItem = evt.data as MediaItemType; - if (props.parentItem?.uri == updatedItem.uri) { - loadData(true); - } else { - for (const provId of updatedItem.provider_ids) { - if ( - provId.prov_type == props.parentItem?.provider && - provId.item_id == props.parentItem?.item_id - ) { - loadData(true); - break; - } - } + if (evt.event == MassEventType.MEDIA_ITEM_ADDED) { + if (props.itemtype.includes((evt.data as MediaItemType).media_type)) { + // signal that there is new content + newContentAvailable.value = true; } - } else if (evt.event == MassEventType.MEDIA_ITEM_ADDED) { - // signal that there is new content - newContentAvailable.value = true; } else if (evt.event == MassEventType.MEDIA_ITEM_DELETED) { items.value = items.value.filter((x) => x.uri != evt.object_id); } else if (evt.event == MassEventType.MEDIA_ITEM_UPDATED) { @@ -566,7 +643,10 @@ export const filteredItems = function ( item.artist?.name.toLowerCase().includes(searchStr) ) { result.push(item); - } else if ("album" in item && item.album?.name.toLowerCase().includes(searchStr)) { + } else if ( + "album" in item && + item.album?.name.toLowerCase().includes(searchStr) + ) { result.push(item); } else if ( "artists" in item && @@ -581,7 +661,9 @@ export const filteredItems = function ( } // sort if (sortBy == "sort_name") { - result.sort((a, b) => (a.sort_name || a.name).localeCompare(b.sort_name || b.name)); + result.sort((a, b) => + (a.sort_name || a.name).localeCompare(b.sort_name || b.name) + ); } if (sortBy == "sort_album") { result.sort((a, b) => @@ -595,14 +677,18 @@ export const filteredItems = function ( } if (sortBy == "track_number") { result.sort( - (a, b) => ((a as Track).track_number || 0) - ((b as Track).track_number || 0) + (a, b) => + ((a as Track).track_number || 0) - ((b as Track).track_number || 0) ); result.sort( - (a, b) => ((a as Track).disc_number || 0) - ((b as Track).disc_number || 0) + (a, b) => + ((a as Track).disc_number || 0) - ((b as Track).disc_number || 0) ); } if (sortBy == "position") { - result.sort((a, b) => ((a as Track).position || 0) - ((b as Track).position || 0)); + result.sort( + (a, b) => ((a as Track).position || 0) - ((b as Track).position || 0) + ); } if (sortBy == "year") { result.sort((a, b) => ((a as Album).year || 0) - ((b as Album).year || 0)); @@ -612,7 +698,9 @@ export const filteredItems = function ( } if (sortBy == "duration") { - result.sort((a, b) => ((a as Track).duration || 0) - ((b as Track).duration || 0)); + result.sort( + (a, b) => ((a as Track).duration || 0) - ((b as Track).duration || 0) + ); } if (sortBy == "provider") { diff --git a/custom_components/mass/frontend/src/components/ListviewItem.vue b/custom_components/mass/frontend/src/components/ListviewItem.vue index 3c683c32..484bca3e 100644 --- a/custom_components/mass/frontend/src/components/ListviewItem.vue +++ b/custom_components/mass/frontend/src/components/ListviewItem.vue @@ -20,21 +20,13 @@ " /> -
+
-
@@ -44,9 +36,7 @@ {{ item.name }} - ({{ item.version }}) + ({{ item.version }}) @@ -66,30 +56,20 @@ @@ -134,9 +112,7 @@
@@ -17,6 +23,7 @@ import { store } from "../plugins/store"; const { t } = useI18n(); const items = ref([]); +const albumArtistsOnly = ref(false); const loadItems = async function ( offset: number, @@ -26,14 +33,21 @@ const loadItems = async function ( inLibraryOnly = true ) { const library = inLibraryOnly || undefined; - return await api.getArtists(offset, limit, sort, library, search); + return await api.getArtists( + offset, + limit, + sort, + library, + search, + albumArtistsOnly.value + ); }; store.topBarTitle = t("artists"); store.topBarContextMenuItems = [ { label: "sync", - labelArgs:[], + labelArgs: [], action: () => { api.startSync(MediaType.ARTIST); }, @@ -43,5 +57,4 @@ store.topBarContextMenuItems = [ onBeforeUnmount(() => { store.topBarContextMenuItems = []; }); - diff --git a/custom_components/mass/media_source.py b/custom_components/mass/media_source.py index 5c07c0cf..9983609d 100644 --- a/custom_components/mass/media_source.py +++ b/custom_components/mass/media_source.py @@ -192,7 +192,8 @@ async def _build_playlists_listing(self, mass: MusicAssistant): await asyncio.gather( *[ self._build_item(mass, item, can_expand=True) - for item in await mass.music.playlists.db_items(True) + # we only grab the first page here becaus ethe HA media browser does not support paging + for item in (await mass.music.playlists.db_items(True)).items ], ), key=lambda x: x.title, @@ -237,7 +238,8 @@ async def _build_artists_listing(self, mass: MusicAssistant): await asyncio.gather( *[ self._build_item(mass, artist, can_expand=True) - for artist in await mass.music.artists.db_items(True) + # we only grab the first page here becaus ethe HA media browser does not support paging + for artist in (await mass.music.artists.db_items(True)).items ], ), key=lambda x: x.title, @@ -280,7 +282,8 @@ async def _build_albums_listing(self, mass: MusicAssistant): await asyncio.gather( *[ self._build_item(mass, album, can_expand=True) - for album in await mass.music.albums.db_items(True) + # we only grab the first page here becaus ethe HA media browser does not support paging + for album in (await mass.music.albums.db_items(True)).items ], ), key=lambda x: x.title, @@ -323,7 +326,8 @@ async def _build_tracks_listing(self, mass: MusicAssistant): await asyncio.gather( *[ self._build_item(mass, track, can_expand=False) - for track in await mass.music.tracks.db_items(True) + # we only grab the first page here becaus ethe HA media browser does not support paging + for track in (await mass.music.tracks.db_items(True)).items ], ), key=lambda x: x.title, @@ -347,7 +351,8 @@ async def _build_radio_listing(self, mass: MusicAssistant): self._build_item( mass, track, can_expand=False, media_class=media_class ) - for track in await mass.music.radio.db_items(True) + # we only grab the first page here becaus ethe HA media browser does not support paging + for track in (await mass.music.radio.db_items(True)).items ], ), ) diff --git a/custom_components/mass/player_controls.py b/custom_components/mass/player_controls.py index b1dffcfd..1b378b55 100644 --- a/custom_components/mass/player_controls.py +++ b/custom_components/mass/player_controls.py @@ -52,6 +52,7 @@ SLIMPROTO_DOMAIN, SLIMPROTO_EVENT, SONOS_DOMAIN, + VOLUMIO_DOMAIN, ) LOGGER = logging.getLogger(__name__) @@ -448,9 +449,14 @@ class KodiPlayer(HassPlayer): async def play_url(self, url: str) -> None: """Play the specified url on the player.""" + if self.mass.streams.base_url not in url: + # use base implementation if 3rd party url provided... + await super().play_url(url) + return + + self.logger.debug("play_url: %s", url) if not self.powered: await self.power(True) - self.logger.debug("play_url: %s", url) if self.state in (PlayerState.PLAYING, PlayerState.PAUSED): await self.stop() @@ -508,6 +514,10 @@ def on_update(self) -> None: async def play_url(self, url: str) -> None: """Play the specified url on the player.""" + if self.mass.streams.base_url not in url: + # use base implementation if 3rd party url provided... + await super().play_url(url) + return self._attr_powered = True if self._attr_use_mute_as_power: await self.volume_mute(False) @@ -519,7 +529,7 @@ async def play_url(self, url: str) -> None: "media_id": url, "media_type": f"audio/{self.active_queue.settings.stream_type.value}", "enqueue": False, - "stream_type": "LIVE", + "stream_type": "BUFFERED", "title": f" Streaming from {DEFAULT_NAME}", } await self.hass.async_add_executor_job( @@ -632,6 +642,11 @@ async def pause(self) -> None: async def play_url(self, url: str) -> None: """Play the specified url on the player.""" self._sonos_paused = False + if self.mass.streams.base_url not in url: + # use base implementation if 3rd party url provided... + await super().play_url(url) + return + self._attr_powered = True if self._attr_use_mute_as_power: await self.volume_mute(False) @@ -683,6 +698,11 @@ class DlnaPlayer(HassPlayer): async def play_url(self, url: str) -> None: """Play the specified url on the player.""" + if self.mass.streams.base_url not in url: + # use base implementation if 3rd party url provided... + await super().play_url(url) + return + if not self.powered: await self.power(True) # pylint: disable=protected-access @@ -730,6 +750,16 @@ def __init__(self, *args, **kwargs) -> None: ) super().__init__(*args, **kwargs) + @property + def default_stream_type(self) -> ContentType: + """Return the default content type to use for streaming.""" + # if any of the players supports FLAC, prefer that + for child_player in self.get_child_players(False, False): + if child_player.default_stream_type == ContentType.FLAC: + return ContentType.FLAC + # fallback to MP3 + return ContentType.MP3 + @property def powered(self) -> bool: """Return power state.""" @@ -899,6 +929,29 @@ def on_child_update(self, player_id: str, changed_keys: set) -> None: super().on_child_update(player_id, changed_keys) +class VolumioPlayer(HassPlayer): + """Representation of Hass player from Volumio integration.""" + + _attr_stream_type: ContentType = ContentType.MP3 + + async def play_url(self, url: str) -> None: + """Play the specified url on the player.""" + # a lot of players do not power on at playback request so send power on from here + if not self.powered: + await self.power(True) + self.logger.debug("play_url: %s", url) + self._attr_current_url = url + await self.entity.async_play_media( + MEDIA_TYPE_MUSIC, + { + "service": "webradio", + "type": "webradio", + "title": DEFAULT_NAME, + "uri": url, + }, + ) + + PLAYER_MAPPING = { CAST_DOMAIN: CastPlayer, DLNA_DOMAIN: DlnaPlayer, @@ -907,6 +960,7 @@ def on_child_update(self, player_id: str, changed_keys: set) -> None: SONOS_DOMAIN: SonosPlayer, GROUP_DOMAIN: HassGroupPlayer, KODI_DOMAIN: KodiPlayer, + VOLUMIO_DOMAIN: VolumioPlayer, } diff --git a/custom_components/mass/translations/frontend/en.json b/custom_components/mass/translations/frontend/en.json index 126b0782..63620b69 100644 --- a/custom_components/mass/translations/frontend/en.json +++ b/custom_components/mass/translations/frontend/en.json @@ -14,6 +14,8 @@ "radios": "Radio", "browse": "Browse", "search": "Search", + "try_global_search": "Try global search", + "topresult": "Top result", "settings": "Settings", "basic_settings": "Basic settings", "advanced_settings": "Advanced settings", @@ -138,7 +140,9 @@ "search": "Show/hide search input", "refresh": "Refresh the items listing", "refresh_new_content": "New content is available, click to refresh the listing", - "select_items": "Select multiple items" + "select_items": "Select multiple items", + "linked": "This provideritem is linked to the current itemdetails", + "album_artist_filter": "Toggle album-artist filter" }, "create_playlist": "Create new playlist on {0}" } diff --git a/custom_components/mass/translations/frontend/nl.json b/custom_components/mass/translations/frontend/nl.json index 887240d0..7d0ee32d 100644 --- a/custom_components/mass/translations/frontend/nl.json +++ b/custom_components/mass/translations/frontend/nl.json @@ -14,6 +14,8 @@ "radios": "Radio", "browse": "Bladeren", "search": "Zoeken", + "try_global_search": "Try global search", + "topresult": "Top resultaat", "settings": "Instellingen", "basic_settings": "Basisinstellingen", "advanced_settings": "Geavanceerde instellingen", @@ -137,7 +139,9 @@ "search": "Toon/verberg zoekinvoer", "refresh": "De lijst verversen", "refresh_new_content": "Er is nieuwe inhoud beschikbaar, klik om te verversen", - "select_items": "Meerdere items selecteren" + "select_items": "Meerdere items selecteren", + "linked": "Dit provideritem is gelinkt aan de itemdetails", + "album_artist_filter": "Schakel filter in/uit om enkel albumartiesten te tonen" }, "create_playlist": "Maak een nieuwe playlist aan op {0}" } diff --git a/custom_components/mass/websockets.py b/custom_components/mass/websockets.py index fce3532b..94eb16a7 100644 --- a/custom_components/mass/websockets.py +++ b/custom_components/mass/websockets.py @@ -127,6 +127,7 @@ async def async_get_mass_func( vol.Optional(SORT, default="sort_name"): str, vol.Optional(LIBRARY): bool, vol.Optional(SEARCH): str, + vol.Optional("album_artists_only"): bool, } ) @websocket_api.async_response @@ -138,9 +139,14 @@ async def websocket_artists( mass: MusicAssistant, ) -> None: """Return artists.""" + if msg.get("album_artists_only"): + func = mass.music.artists.album_artists + else: + func = mass.music.artists.db_items + await connection.send_big_result( msg[ID], - await mass.music.artists.db_items( + await func( msg.get(LIBRARY), msg.get(SEARCH), limit=msg[LIMIT], @@ -878,7 +884,12 @@ async def websocket_delete_db_item( @websocket_api.websocket_command( - {vol.Required(TYPE): f"{DOMAIN}/search", vol.Required("query"): str} + { + vol.Required(TYPE): f"{DOMAIN}/search", + vol.Required("query"): str, + vol.Optional("media_types", default=MediaType.ALL): list, + vol.Optional("limit", default=5): int, + } ) @websocket_api.async_response @async_get_mass @@ -889,7 +900,9 @@ async def websocket_search( mass: MusicAssistant, ) -> None: """Return Browse items.""" - result = await mass.music.search(msg.get(URI)) + result = await mass.music.search( + msg["query"], media_types=msg["media_types"], limit=msg["limit"] + ) result = [x.to_dict() for x in result] await connection.send_big_result(