diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87b1dab --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +.idea +*.iml +.vscode/** + +DistributionTool +DistributionTool.exe +release \ No newline at end of file diff --git a/README.md b/README.md index ff10594..dc172c5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,32 @@ -# streamdeck-sonos -Elgato Stream Deck Sonos Plugin +# Sonos Plugin for Elgato Stream Deck +Plugin which allows to control `Sonos` speakers and get live feedback. + +![](screenshot.png) + +# Features +- Code written in JavaScript +- Cross-platform (macOS, Windows) +- Play / Pause with Live Feedback (Album Cover, Artist, Title, Time) +- Previous & Next Track +- Change Input Source (Line In, TV, Queue) +- Play URL from Spotify, TuneIn, Tidal, Deezer and Apple Music +- Play Sonos Favorites +- Change Repeat & Shuffle Mode +- Change Volume + +# Limitations + +The plugin is written in JavaScript which makes it Cross-Platform compatible and can be trusted to not perform anything dangerous. + +However, this also comes with some limitations: +- Speaker Auto Discovery cannot be performed. +The IP address of the speaker to control has to be entered manually but the IP can be easily retrieved from the Sonos App. +- Writing to Files not possible. +- Accessing the clipboard not possible. +- Can't listen to Push Notifications. +The plugin has to poll for status changes. + +# Installation +Download the plugin from the [Releases](https://github.com/GenericMale/streamdeck-sonos/releases/) section. + +If you double-click the `com.genericmale.sonos.streamDeckPlugin` file on your machine, Stream Deck will install the plugin. diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..193f88c Binary files /dev/null and b/screenshot.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/de.json b/src/com.genericmale.sonos.sdPlugin/de.json new file mode 100644 index 0000000..c609483 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/de.json @@ -0,0 +1,85 @@ +{ + "Description": "Steuere deine Sonos Lautsprecher.", + "Name": "Sonos", + "Category": "Sonos", + "Localization": { + "Speaker IP": "Lautsprecher IP", + "Obtaining Speaker IP Address": "Lautsprecher IP feststellen", + "Sonos Desktop App": "Sonos Desktop App", + "Help -> About My Sonos System...": "Hilfe -> Über mein Sonos System...", + "Sonos Mobile App": "Sonos Handy App", + "Settings -> System -> About My System": "Einstellungen -> System -> Über mein System", + "Refresh Interval": "Intervall", + "Volume": "Lautstärke", + "Volume Increase": "Lautstärke Erhöhen", + "Volume Decrease": "Lautstärke Verringern", + "URL": "URL", + "Source": "Quelle", + "TV": "TV", + "Line In": "Line In", + "Sonos Queue": "Sonos Warteschlange", + "Favorite": "Favorit", + "Album Art": "Album", + "Display on key": "Auf Taste zeigen", + "Play": "Abspielen", + "Top Text": "Text Oben", + "Middle Text": "Text Mitte", + "Bottom Text": "Text Unten", + "None": "Nichts", + "Artist": "Künstler", + "Title": "Titel", + "Duration": "Dauer", + "Elapsed": "Vergangen", + "Remaining": "Verbleibend", + "Paused Text": "Pausieren Text" + }, + + "com.genericmale.sonos.playpause": { + "Name": "Abspielen / Stoppen", + "Tooltip": "Wiedergabe starten / stoppen und Wiedergabeinformationen anzeigen." + }, + "com.genericmale.sonos.previous": { + "Name": "Zurück", + "Tooltip": "Zum vorherigen Lied in der Wartscheschlange springen." + }, + "com.genericmale.sonos.next": { + "Name": "Weiter", + "Tooltip": "Zum nächsten Lied in der Wartscheschlange springen." + }, + "com.genericmale.sonos.changesource": { + "Name": "Quelle ändern", + "Tooltip": "Die Wiedergabequelle (TV, Line In, Warteschlange) ändern." + }, + "com.genericmale.sonos.playuri": { + "Name": "URL wiedergeben", + "Tooltip": "Ein Lied, Album, Playlist oder Radio Station mit einer URL von Spotify, TuneIn, Tidal, Deezer oder Apple Music abspielen." + }, + "com.genericmale.sonos.playfavorites": { + "Name": "Favoriten wiedergeben", + "Tooltip": "Play one of the favorites added in the Sonos app." + }, + "com.genericmale.sonos.repeat": { + "Name": "Wiederholungsmodus", + "Tooltip": "Den Wiederholungsmodus umschalten." + }, + "com.genericmale.sonos.shuffle": { + "Name": "Zufällige Wiedergabe", + "Tooltip": "Die zufällige Wiedergabe umschalten." + }, + "com.genericmale.sonos.volumeup": { + "Name": "Lautstärke erhöhen", + "Tooltip": "Die Lautstärke erhöhen." + }, + "com.genericmale.sonos.volumedown": { + "Name": "Lautstärke verringern", + "Tooltip": "Die Lautstärke verringern." + }, + "com.genericmale.sonos.mute": { + "Name": "Stummschalten", + "Tooltip": "Die Stummschaltung des Tons umschalten." + }, + "com.genericmale.sonos.volume": { + "Name": "Lautstärke setzen", + "Tooltip": "Die Lautstärke auf einen fixen Wert ändern." + } +} diff --git a/src/com.genericmale.sonos.sdPlugin/en.json b/src/com.genericmale.sonos.sdPlugin/en.json new file mode 100644 index 0000000..f681011 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/en.json @@ -0,0 +1,85 @@ +{ + "Description": "Control your Sonos Speakers.", + "Name": "Sonos", + "Category": "Sonos", + "Localization": { + "Speaker IP": "Speaker IP", + "Obtaining Speaker IP Address": "Obtaining Speaker IP Address", + "Sonos Desktop App": "Sonos Desktop App", + "Help -> About My Sonos System...": "Help -> About My Sonos System...", + "Sonos Mobile App": "Sonos Mobile App", + "Settings -> System -> About My System": "Settings -> System -> About My System", + "Refresh Interval": "Refresh Interval", + "Volume": "Volume", + "Volume Increase": "Volume Increase", + "Volume Decrease": "Volume Decrease", + "URL": "URL", + "Source": "Source", + "TV": "TV", + "Line In": "Line In", + "Sonos Queue": "Sonos Queue", + "Favorite": "Favorite", + "Album Art": "Album Art", + "Display on key": "Display on key", + "Play": "Play", + "Top Text": "Top Text", + "Middle Text": "Middle Text", + "Bottom Text": "Bottom Text", + "None": "None", + "Artist": "Artist", + "Title": "Title", + "Duration": "Duration", + "Elapsed": "Elapsed", + "Remaining": "Remaining", + "Paused Text": "Paused Text" + }, + + "com.genericmale.sonos.playpause": { + "Name": "Play / Pause", + "Tooltip": "Play and pause the audio and display playback info." + }, + "com.genericmale.sonos.previous": { + "Name": "Previous Song", + "Tooltip": "Go back to the previous song in queue." + }, + "com.genericmale.sonos.next": { + "Name": "Next Song", + "Tooltip": "Go to next song in queue." + }, + "com.genericmale.sonos.changesource": { + "Name": "Change Source", + "Tooltip": "Change the playback source (TV, Line In, Queue)." + }, + "com.genericmale.sonos.playuri": { + "Name": "Play URL", + "Tooltip": "Play a song, album, playlist or radio station with a URL from Spotify, TuneIn, Tidal, Deezer or Apple Music." + }, + "com.genericmale.sonos.playfavorites": { + "Name": "Play Favorites", + "Tooltip": "Play one of the favorites added in the Sonos app." + }, + "com.genericmale.sonos.repeat": { + "Name": "Repeat Mode", + "Tooltip": "Toggle the repeat mode." + }, + "com.genericmale.sonos.shuffle": { + "Name": "Shuffle Mode", + "Tooltip": "Toggle the shuffle mode." + }, + "com.genericmale.sonos.volumeup": { + "Name": "Volume Up", + "Tooltip": "Increase the volume." + }, + "com.genericmale.sonos.volumedown": { + "Name": "Volume Down", + "Tooltip": "Decrease the volume." + }, + "com.genericmale.sonos.mute": { + "Name": "Volume Mute / Unmute", + "Tooltip": "Mute and unmute the audio." + }, + "com.genericmale.sonos.volume": { + "Name": "Volume Set", + "Tooltip": "Change the volume to a fixed value." + } +} diff --git a/src/com.genericmale.sonos.sdPlugin/images/changesource_action.png b/src/com.genericmale.sonos.sdPlugin/images/changesource_action.png new file mode 100644 index 0000000..4514889 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/changesource_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/changesource_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/changesource_action@2x.png new file mode 100644 index 0000000..8a74aca Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/changesource_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/changesource_key.png b/src/com.genericmale.sonos.sdPlugin/images/changesource_key.png new file mode 100644 index 0000000..7821105 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/changesource_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/changesource_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/changesource_key@2x.png new file mode 100644 index 0000000..a491e36 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/changesource_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/mute_action.png b/src/com.genericmale.sonos.sdPlugin/images/mute_action.png new file mode 100644 index 0000000..482428b Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/mute_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/mute_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/mute_action@2x.png new file mode 100644 index 0000000..9eed1b5 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/mute_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/muted_key.png b/src/com.genericmale.sonos.sdPlugin/images/muted_key.png new file mode 100644 index 0000000..729904b Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/muted_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/muted_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/muted_key@2x.png new file mode 100644 index 0000000..e140bc3 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/muted_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/next_action.png b/src/com.genericmale.sonos.sdPlugin/images/next_action.png new file mode 100644 index 0000000..980e313 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/next_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/next_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/next_action@2x.png new file mode 100644 index 0000000..cfdec5e Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/next_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/next_key.png b/src/com.genericmale.sonos.sdPlugin/images/next_key.png new file mode 100644 index 0000000..c02832b Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/next_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/next_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/next_key@2x.png new file mode 100644 index 0000000..749e165 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/next_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/paused_key.png b/src/com.genericmale.sonos.sdPlugin/images/paused_key.png new file mode 100644 index 0000000..1405ab5 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/paused_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/paused_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/paused_key@2x.png new file mode 100644 index 0000000..5203a69 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/paused_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playfavorites_action.png b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_action.png new file mode 100644 index 0000000..1d30f61 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playfavorites_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_action@2x.png new file mode 100644 index 0000000..04ccd12 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playfavorites_key.png b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_key.png new file mode 100644 index 0000000..4b01e50 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playfavorites_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_key@2x.png new file mode 100644 index 0000000..c13a274 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playfavorites_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playing_key.png b/src/com.genericmale.sonos.sdPlugin/images/playing_key.png new file mode 100644 index 0000000..70cca37 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playing_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playing_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/playing_key@2x.png new file mode 100644 index 0000000..52b6f5f Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playing_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playpause_action.png b/src/com.genericmale.sonos.sdPlugin/images/playpause_action.png new file mode 100644 index 0000000..198f7f8 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playpause_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playpause_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/playpause_action@2x.png new file mode 100644 index 0000000..d17088e Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playpause_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playuri_action.png b/src/com.genericmale.sonos.sdPlugin/images/playuri_action.png new file mode 100644 index 0000000..71c33e0 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playuri_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playuri_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/playuri_action@2x.png new file mode 100644 index 0000000..59ad173 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playuri_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playuri_key.png b/src/com.genericmale.sonos.sdPlugin/images/playuri_key.png new file mode 100644 index 0000000..5283898 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playuri_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/playuri_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/playuri_key@2x.png new file mode 100644 index 0000000..6e2b868 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/playuri_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/plugin_category.png b/src/com.genericmale.sonos.sdPlugin/images/plugin_category.png new file mode 100644 index 0000000..42c135d Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/plugin_category.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/plugin_category@2x.png b/src/com.genericmale.sonos.sdPlugin/images/plugin_category@2x.png new file mode 100644 index 0000000..489ec0c Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/plugin_category@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/plugin_icon.png b/src/com.genericmale.sonos.sdPlugin/images/plugin_icon.png new file mode 100644 index 0000000..d7fe82e Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/plugin_icon.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/plugin_icon@2x.png b/src/com.genericmale.sonos.sdPlugin/images/plugin_icon@2x.png new file mode 100644 index 0000000..083d2cd Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/plugin_icon@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/previous_action.png b/src/com.genericmale.sonos.sdPlugin/images/previous_action.png new file mode 100644 index 0000000..bf28a3d Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/previous_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/previous_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/previous_action@2x.png new file mode 100644 index 0000000..0c5eb7b Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/previous_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/previous_key.png b/src/com.genericmale.sonos.sdPlugin/images/previous_key.png new file mode 100644 index 0000000..239f47a Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/previous_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/previous_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/previous_key@2x.png new file mode 100644 index 0000000..d9c3fa3 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/previous_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_action.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_action.png new file mode 100644 index 0000000..924c9fa Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_action@2x.png new file mode 100644 index 0000000..160ed80 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_all_key.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_all_key.png new file mode 100644 index 0000000..1312d30 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_all_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_all_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_all_key@2x.png new file mode 100644 index 0000000..b95b25b Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_all_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_none_key.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_none_key.png new file mode 100644 index 0000000..fb96454 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_none_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_none_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_none_key@2x.png new file mode 100644 index 0000000..0ca5eec Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_none_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_one_key.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_one_key.png new file mode 100644 index 0000000..f611fff Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_one_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/repeat_one_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/repeat_one_key@2x.png new file mode 100644 index 0000000..fa3c87c Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/repeat_one_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/shuffle_action.png b/src/com.genericmale.sonos.sdPlugin/images/shuffle_action.png new file mode 100644 index 0000000..84b1807 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/shuffle_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/shuffle_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/shuffle_action@2x.png new file mode 100644 index 0000000..728caba Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/shuffle_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/shuffle_off_key.png b/src/com.genericmale.sonos.sdPlugin/images/shuffle_off_key.png new file mode 100644 index 0000000..92eae33 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/shuffle_off_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/shuffle_off_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/shuffle_off_key@2x.png new file mode 100644 index 0000000..90d2837 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/shuffle_off_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/shuffle_on_key.png b/src/com.genericmale.sonos.sdPlugin/images/shuffle_on_key.png new file mode 100644 index 0000000..6c2106c Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/shuffle_on_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/shuffle_on_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/shuffle_on_key@2x.png new file mode 100644 index 0000000..fe8f259 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/shuffle_on_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/unmuted_key.png b/src/com.genericmale.sonos.sdPlugin/images/unmuted_key.png new file mode 100644 index 0000000..e1f0d2a Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/unmuted_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/unmuted_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/unmuted_key@2x.png new file mode 100644 index 0000000..6ca2329 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/unmuted_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volume_action.png b/src/com.genericmale.sonos.sdPlugin/images/volume_action.png new file mode 100644 index 0000000..8cabc31 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volume_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volume_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/volume_action@2x.png new file mode 100644 index 0000000..45ab0c6 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volume_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volume_key.png b/src/com.genericmale.sonos.sdPlugin/images/volume_key.png new file mode 100644 index 0000000..ae5cb8c Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volume_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volume_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/volume_key@2x.png new file mode 100644 index 0000000..76ee6c6 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volume_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumedown_action.png b/src/com.genericmale.sonos.sdPlugin/images/volumedown_action.png new file mode 100644 index 0000000..0dbc96d Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumedown_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumedown_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/volumedown_action@2x.png new file mode 100644 index 0000000..3972fd5 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumedown_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumedown_key.png b/src/com.genericmale.sonos.sdPlugin/images/volumedown_key.png new file mode 100644 index 0000000..55b8e1a Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumedown_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumedown_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/volumedown_key@2x.png new file mode 100644 index 0000000..ed9d8e6 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumedown_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumeup_action.png b/src/com.genericmale.sonos.sdPlugin/images/volumeup_action.png new file mode 100644 index 0000000..64572a3 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumeup_action.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumeup_action@2x.png b/src/com.genericmale.sonos.sdPlugin/images/volumeup_action@2x.png new file mode 100644 index 0000000..0efca72 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumeup_action@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumeup_key.png b/src/com.genericmale.sonos.sdPlugin/images/volumeup_key.png new file mode 100644 index 0000000..6f22c45 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumeup_key.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/images/volumeup_key@2x.png b/src/com.genericmale.sonos.sdPlugin/images/volumeup_key@2x.png new file mode 100644 index 0000000..76ee6c6 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/images/volumeup_key@2x.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/manifest.json b/src/com.genericmale.sonos.sdPlugin/manifest.json new file mode 100644 index 0000000..8dda4d9 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/manifest.json @@ -0,0 +1,192 @@ +{ + "SDKVersion": 2, + "Author": "GenericMale", + "CodePath": "plugin/index.html", + "Description": "Control your Sonos Speakers.", + "Name": "Sonos", + "Icon": "images/plugin_icon", + "URL": "https://github.com/GenericMale/streamdeck-spotify", + "Version": "1.0.0", + "Category": "Sonos", + "CategoryIcon": "images/plugin_category", + "PropertyInspectorPath": "pi/index.html", + "OS": [ + { + "Platform": "mac", + "MinimumVersion": "10.11" + }, + { + "Platform": "windows", + "MinimumVersion": "10" + } + ], + "Software": { + "MinimumVersion": "4.1" + }, + "Actions": [ + { + "Icon": "images/playpause_action", + "Name": "Play / Pause", + "States": [ + { + "Image": "images/paused_key", + "Name": "Paused", + "TitleColor": "#ffffff", + "FontFamily": "Arial", + "FontStyle": "Bold", + "FontSize": "10" + }, + { + "Image": "images/playing_key", + "Name": "Playing", + "TitleColor": "#ffffff", + "FontFamily": "Arial", + "FontStyle": "Bold", + "FontSize": "10" + } + ], + "Tooltip": "Play and pause the audio and display playback info.", + "UUID": "com.genericmale.sonos.playpause" + }, + { + "Icon": "images/previous_action", + "Name": "Previous Song", + "States": [ + { + "Image": "images/previous_key" + } + ], + "Tooltip": "Go back to the previous song in queue.", + "UUID": "com.genericmale.sonos.previous" + }, + { + "Icon": "images/next_action", + "Name": "Next Song", + "States": [ + { + "Image": "images/next_key" + } + ], + "Tooltip": "Go to next song in queue.", + "UUID": "com.genericmale.sonos.next" + }, + { + "Icon": "images/changesource_action", + "Name": "Change Source", + "States": [ + { + "Image": "images/changesource_key" + } + ], + "Tooltip": "Change the playback source (TV, LineIn, Queue).", + "UUID": "com.genericmale.sonos.changesource" + }, + { + "Icon": "images/playuri_action", + "Name": "Play URL", + "States": [ + { + "Image": "images/playuri_key" + } + ], + "Tooltip": "Play a song, album, playlist or radio station with a URL from Spotify, TuneIn, Tidal, Deezer or Apple Music.", + "UUID": "com.genericmale.sonos.playuri" + }, + { + "Icon": "images/playfavorites_action", + "Name": "Play Favorites", + "States": [ + { + "Image": "images/playfavorites_key" + } + ], + "Tooltip": "Play one of the favorites added in the Sonos app.", + "UUID": "com.genericmale.sonos.playfavorites" + }, + { + "Icon": "images/repeat_action", + "Name": "Repeat Mode", + "States": [ + { + "Image": "images/repeat_none_key", + "Name": "No Repeat" + }, + { + "Image": "images/repeat_all_key", + "Name": "Repeat All" + }, + { + "Image": "images/repeat_one_key", + "Name": "Repeat Track" + } + ], + "Tooltip": "Toggle the repeat mode.", + "UUID": "com.genericmale.sonos.repeat" + }, + { + "Icon": "images/shuffle_action", + "Name": "Shuffle Mode", + "States": [ + { + "Image": "images/shuffle_off_key", + "Name": "No Shuffle" + }, + { + "Image": "images/shuffle_on_key", + "Name": "Shuffle" + } + ], + "Tooltip": "Toggle the shuffle mode.", + "UUID": "com.genericmale.sonos.shuffle" + }, + { + "Icon": "images/volumeup_action", + "Name": "Volume Up", + "States": [ + { + "Image": "images/volumeup_key" + } + ], + "Tooltip": "Increase the volume.", + "UUID": "com.genericmale.sonos.volumeup" + }, + { + "Icon": "images/volumedown_action", + "Name": "Volume Down", + "States": [ + { + "Image": "images/volumedown_key" + } + ], + "Tooltip": "Decrease the volume.", + "UUID": "com.genericmale.sonos.volumedown" + }, + { + "Icon": "images/mute_action", + "Name": "Volume Mute / Unmute", + "States": [ + { + "Image": "images/unmuted_key", + "Name": "Unmuted" + }, + { + "Image": "images/muted_key", + "Name": "Muted" + } + ], + "Tooltip": "Mute and unmute the audio.", + "UUID": "com.genericmale.sonos.mute" + }, + { + "Icon": "images/volume_action", + "Name": "Volume Set", + "States": [ + { + "Image": "images/volume_key" + } + ], + "Tooltip": "Change the volume to a fixed value.", + "UUID": "com.genericmale.sonos.volume" + } + ] +} diff --git a/src/com.genericmale.sonos.sdPlugin/pi/css/caret.svg b/src/com.genericmale.sonos.sdPlugin/pi/css/caret.svg new file mode 100644 index 0000000..b69162a --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/css/caret.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/com.genericmale.sonos.sdPlugin/pi/css/check.png b/src/com.genericmale.sonos.sdPlugin/pi/css/check.png new file mode 100644 index 0000000..b5e9e62 Binary files /dev/null and b/src/com.genericmale.sonos.sdPlugin/pi/css/check.png differ diff --git a/src/com.genericmale.sonos.sdPlugin/pi/css/check.svg b/src/com.genericmale.sonos.sdPlugin/pi/css/check.svg new file mode 100644 index 0000000..5b96af0 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/css/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/com.genericmale.sonos.sdPlugin/pi/css/elg_calendar.svg b/src/com.genericmale.sonos.sdPlugin/pi/css/elg_calendar.svg new file mode 100644 index 0000000..157e01b --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/css/elg_calendar.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/com.genericmale.sonos.sdPlugin/pi/css/elg_calendar_inv.svg b/src/com.genericmale.sonos.sdPlugin/pi/css/elg_calendar_inv.svg new file mode 100644 index 0000000..4f8af68 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/css/elg_calendar_inv.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/com.genericmale.sonos.sdPlugin/pi/css/rcheck.svg b/src/com.genericmale.sonos.sdPlugin/pi/css/rcheck.svg new file mode 100644 index 0000000..af478ee --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/css/rcheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/com.genericmale.sonos.sdPlugin/pi/css/sdpi.css b/src/com.genericmale.sonos.sdPlugin/pi/css/sdpi.css new file mode 100644 index 0000000..c9ebafe --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/css/sdpi.css @@ -0,0 +1,1631 @@ +:root { + --sdpi-bgcolor: #2D2D2D; + --sdpi-background: #3D3D3D; + --sdpi-color: #d8d8d8; + --sdpi-bordercolor: #3a3a3a; + --sdpi-buttonbordercolor: #969696; + --sdpi-borderradius: 0px; + --sdpi-width: 224px; + --sdpi-fontweight: 600; + --sdpi-letterspacing: -0.25pt; +} + +html { + --sdpi-bgcolor: #2D2D2D; + --sdpi-background: #3D3D3D; + --sdpi-color: #d8d8d8; + --sdpi-bordercolor: #3a3a3a; + --sdpi-buttonbordercolor: #969696; + --sdpi-borderradius: 0px; + --sdpi-width: 224px; + --sdpi-fontweight: 600; + --sdpi-letterspacing: -0.25pt; + height: 100%; + width: 100%; + overflow: hidden; + touch-action:none; +} + +html, body { + --sdpi-bgcolor: #2D2D2D; + --sdpi-background: #3D3D3D; + --sdpi-color: #d8d8d8; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 9pt; + background-color: var(--sdpi-bgcolor); + color: #9a9a9a; +} + +body { + height: 100%; + padding: 0; + overflow-x: hidden; + overflow-y: auto; + margin: 0; + -webkit-overflow-scrolling: touch; + -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; +} + +mark { + background-color: var(--sdpi-bgcolor); + color: var(--sdpi-color); +} + +hr, hr2 { + -webkit-margin-before: 1em; + -webkit-margin-after: 1em; + border-style: none; + background: var(--sdpi-background); + height: 1px; +} + +hr2, +.sdpi-heading { + display: flex; + flex-basis: 100%; + align-items: center; + color: inherit; + font-size: 9pt; + margin: 8px 0px; +} + +.sdpi-heading::before, +.sdpi-heading::after { + content: ""; + flex-grow: 1; + background: var(--sdpi-background); + height: 1px; + font-size: 0px; + line-height: 0px; + margin: 0px 16px; +} + +hr2 { + height: 2px; +} + +hr, hr2 { + margin-left:16px; + margin-right:16px; +} + +.sdpi-item-value, +option, +input, +select, +button { + font-size: 10pt; + font-weight: var(--sdpi-fontweight); + letter-spacing: var(--sdpi-letterspacing); +} + + + +.win .sdpi-item-value, +.win option, +.win input, +.win select, +.win button { + font-size: 11px; + font-style: normal; + letter-spacing: inherit; + font-weight: 100; +} + +.win button { + font-size: 12px; +} + +::-webkit-progress-value, +meter::-webkit-meter-optimum-value { + border-radius: 2px; + /* background: linear-gradient(#ccf, #99f 20%, #77f 45%, #77f 55%, #cdf); */ +} + +::-webkit-progress-bar, +meter::-webkit-meter-bar { + border-radius: 3px; + background: var(--sdpi-background); +} + +::-webkit-progress-bar:active, +meter::-webkit-meter-bar:active { + border-radius: 3px; + background: #222222; +} +::-webkit-progress-value:active, +meter::-webkit-meter-optimum-value:active { + background: #99f; +} + +progress, +progress.sdpi-item-value { + min-height: 5px !important; + height: 5px; + background-color: #303030; +} + +progress { + margin-top: 8px !important; + margin-bottom: 8px !important; +} + +.full progress, +progress.full { + margin-top: 3px !important; +} + +::-webkit-progress-inner-element { + background-color: transparent; +} + + +.sdpi-item[type="progress"] { + margin-top: 4px !important; + margin-bottom: 12px; + min-height: 15px; +} + +.sdpi-item-child.full:last-child { + margin-bottom: 4px; +} + +.tabs { + /** + * Setting display to flex makes this container lay + * out its children using flexbox, the exact same + * as in the above "Stepper input" example. + */ + display: flex; + + border-bottom: 1px solid #D7DBDD; +} + +.tab { + cursor: pointer; + padding: 5px 30px; + color: #16a2d7; + font-size: 9pt; + border-bottom: 2px solid transparent; +} + +.tab.is-tab-selected { + border-bottom-color: #4ebbe4; +} + +select { + -webkit-appearance: none; + -moz-appearance: none; + -o-appearance: none; + appearance: none; + background: url(caret.svg) no-repeat 97% center; +} + +label.sdpi-file-label, +input[type="button"], +input[type="submit"], +input[type="reset"], +input[type="file"], +input[type=file]::-webkit-file-upload-button, +button, +select { + color: var(--sdpi-color); + border: 1pt solid #303030; + font-size: 8pt; + background-color: var(--sdpi-background); + border-radius: var(--sdpi-borderradius); +} + +label.sdpi-file-label, +input[type="button"], +input[type="submit"], +input[type="reset"], +input[type="file"], +input[type=file]::-webkit-file-upload-button, +button { + border: 1pt solid var(--sdpi-buttonbordercolor); + border-radius: var(--sdpi-borderradius); + border-color: var(--sdpi-buttonbordercolor); + min-height: 23px !important; + height: 23px !important; + margin-right: 8px; +} + +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="file"] { + border-radius: var(--sdpi-borderradius); + max-width: 220px; +} + +option { + height: 1.5em; + padding: 4px; +} + +/* SDPI */ + +.sdpi-wrapper { + overflow-x: hidden; + height: 100%; +} + +.sdpi-item { + display: flex; + flex-direction: row; + min-height: 32px; + align-items: center; + margin-top: 2px; + max-width: 344px; + /* -webkit-user-drag: none; */ +} + +.sdpi-item:first-child { + margin-top:-1px; +} + +.sdpi-item:last-child { + margin-bottom: 0px; +} + +.sdpi-item > *:not(.sdpi-item-label):not(meter):not(details):not(canvas) { + min-height: 26px; + padding: 0px 4px 0px 4px; +} + +.sdpi-item > *:not(.sdpi-item-label.empty):not(meter) { + min-height: 26px; + padding: 0px 4px 0px 4px; +} + + +.sdpi-item-group { + padding: 0 !important; +} + +meter.sdpi-item-value { + margin-left: 6px; +} + +.sdpi-item[type="group"] { + display: block; + margin-top: 12px; + margin-bottom: 12px; + /* border: 1px solid white; */ + flex-direction: unset; + text-align: left; +} + +.sdpi-item[type="group"] > .sdpi-item-label, +.sdpi-item[type="group"].sdpi-item-label { + width: 96%; + text-align: left; + font-weight: 700; + margin-bottom: 4px; + padding-left: 4px; +} + +dl, +ul, +ol { + -webkit-margin-before: 0px; + -webkit-margin-after: 4px; + -webkit-padding-start: 1em; + max-height: 90px; + overflow-y: scroll; + cursor: pointer; + user-select: none; +} + +table.sdpi-item-value, +dl.sdpi-item-value, +ul.sdpi-item-value, +ol.sdpi-item-value { + -webkit-margin-before: 4px; + -webkit-margin-after: 8px; + -webkit-padding-start: 1em; + width: var(--sdpi-width); + text-align: center; +} + +table > caption { + margin: 2px; +} + +.list, +.sdpi-item[type="list"] { + align-items: baseline; +} + +.sdpi-item-label { + text-align: right; + flex: none; + width: 94px; + padding-right: 4px; + font-weight: 600; + -webkit-user-select: none; +} + +.win .sdpi-item-label, +.sdpi-item-label > small{ + font-weight: normal; +} + +.sdpi-item-label:after { + content: ": "; +} + +.sdpi-item-label.empty:after { + content: ""; +} + +.sdpi-test, +.sdpi-item-value { + flex: 1 0 0; + /* flex-grow: 1; + flex-shrink: 0; */ + margin-right: 14px; + margin-left: 4px; + justify-content: space-evenly; +} + +canvas.sdpi-item-value { + max-width: 144px; + max-height: 144px; + width: 144px; + height: 144px; + margin: 0 auto; + cursor: pointer; +} + +input.sdpi-item-value { + margin-left: 5px; +} + +.sdpi-item-value button, +button.sdpi-item-value { + margin-left: 6px; + margin-right: 14px; +} + +.sdpi-item-value.range { + margin-left: 0px; +} + +table, +dl.sdpi-item-value, +ul.sdpi-item-value, +ol.sdpi-item-value, +.sdpi-item-value > dl, +.sdpi-item-value > ul, +.sdpi-item-value > ol +{ + list-style-type: none; + list-style-position: outside; + margin-left: -4px; + margin-right: -4px; + padding: 4px; + border: 1px solid var(--sdpi-bordercolor); +} + +dl.sdpi-item-value, +ul.sdpi-item-value, +ol.sdpi-item-value, +.sdpi-item-value > ol { + list-style-type: none; + list-style-position: inside; + margin-left: 5px; + margin-right: 12px; + padding: 4px !important; + display: flex; + flex-direction: column; +} + +.two-items li { + display: flex; +} +.two-items li > *:first-child { + flex: 0 0 50%; + text-align: left; +} +.two-items.thirtyseventy li > *:first-child { + flex: 0 0 30%; +} + +ol.sdpi-item-value, +.sdpi-item-value > ol[listtype="none"] { + list-style-type: none; +} +ol.sdpi-item-value[type="decimal"], +.sdpi-item-value > ol[type="decimal"] { + list-style-type: decimal; +} + +ol.sdpi-item-value[type="decimal-leading-zero"], +.sdpi-item-value > ol[type="decimal-leading-zero"] { + list-style-type: decimal-leading-zero; +} + +ol.sdpi-item-value[type="lower-alpha"], +.sdpi-item-value > ol[type="lower-alpha"] { + list-style-type: lower-alpha; +} + +ol.sdpi-item-value[type="upper-alpha"], +.sdpi-item-value > ol[type="upper-alpha"] { + list-style-type: upper-alpha; +} + +ol.sdpi-item-value[type="upper-roman"], +.sdpi-item-value > ol[type="upper-roman"] { + list-style-type: upper-roman; +} + +ol.sdpi-item-value[type="lower-roman"], +.sdpi-item-value > ol[type="lower-roman"] { + list-style-type: upper-roman; +} + +tr:nth-child(even), +.sdpi-item-value > ul > li:nth-child(even), +.sdpi-item-value > ol > li:nth-child(even), +li:nth-child(even) { + background-color: rgba(0,0,0,.2) +} + +td:hover, +.sdpi-item-value > ul > li:hover:nth-child(even), +.sdpi-item-value > ol > li:hover:nth-child(even), +li:hover:nth-child(even), +li:hover { + background-color: rgba(255,255,255,.1); +} + +td.selected, +td.selected:hover, +li.selected:hover, +li.selected { + color: white; + background-color: #77f; +} + +tr { + border: 1px solid var(--sdpi-bordercolor); +} + +td { + border-right: 1px solid var(--sdpi-bordercolor); + -webkit-user-select: none; +} + +tr:last-child, +td:last-child { + border: none; +} + +.sdpi-item-value.select, +.sdpi-item-value > select { + margin-right: 13px; + margin-left: 4px; +} + +.sdpi-item-child, +.sdpi-item-group > .sdpi-item > input[type="color"] { + margin-top: 0.4em; + margin-right: 4px; +} + +.full, +.full *, +.sdpi-item-value.full, +.sdpi-item-child > full > *, +.sdpi-item-child.full, +.sdpi-item-child.full > *, +.full > .sdpi-item-child, +.full > .sdpi-item-child > *{ + display: flex; + flex: 1 1 0; + margin-bottom: 4px; + margin-left: 0px; + width: 100%; + + justify-content: space-evenly; +} + +.sdpi-item-group > .sdpi-item > input[type="color"] { + margin-top: 0px; +} + +::-webkit-calendar-picker-indicator:focus, +input[type=file]::-webkit-file-upload-button:focus, +button:focus, +textarea:focus, +input:focus, +select:focus, +option:focus, +details:focus, +summary:focus, +.custom-select select { + outline: none; +} + +summary { + cursor: default; + -webkit-user-select: none; +} + +.pointer, +summary .pointer { + cursor: pointer; +} + +details * { + font-size: 12px; + font-weight: normal; +} + +details *:not(summary) { + margin-left: 13px +} + +details.message { + padding: 4px 18px 4px 12px; +} + +/*details.message summary { + font-size: 10pt; + font-weight: 600; + min-height: 18px; +}*/ + +details.message:first-child { + margin-top: 4px; + margin-left: 0; + padding-left: 102px; +} + +details.message h1 { + text-align: left; +} + +/*.message > summary::-webkit-details-marker { + display: none; +}*/ + +.info20, +.question, +.caution, +.info { + background-repeat: no-repeat; + background-position: 72px center; +} + +.info20 { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath fill='%23999' d='M10,20 C4.4771525,20 0,15.5228475 0,10 C0,4.4771525 4.4771525,0 10,0 C15.5228475,0 20,4.4771525 20,10 C20,15.5228475 15.5228475,20 10,20 Z M10,8 C8.8954305,8 8,8.84275812 8,9.88235294 L8,16.1176471 C8,17.1572419 8.8954305,18 10,18 C11.1045695,18 12,17.1572419 12,16.1176471 L12,9.88235294 C12,8.84275812 11.1045695,8 10,8 Z M10,3 C8.8954305,3 8,3.88165465 8,4.96923077 L8,5.03076923 C8,6.11834535 8.8954305,7 10,7 C11.1045695,7 12,6.11834535 12,5.03076923 L12,4.96923077 C12,3.88165465 11.1045695,3 10,3 Z'/%3E%3C/svg%3E%0A"); +} + +.info { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath fill='%23999' d='M10,18 C5.581722,18 2,14.418278 2,10 C2,5.581722 5.581722,2 10,2 C14.418278,2 18,5.581722 18,10 C18,14.418278 14.418278,18 10,18 Z M10,8 C9.44771525,8 9,8.42137906 9,8.94117647 L9,14.0588235 C9,14.5786209 9.44771525,15 10,15 C10.5522847,15 11,14.5786209 11,14.0588235 L11,8.94117647 C11,8.42137906 10.5522847,8 10,8 Z M10,5 C9.44771525,5 9,5.44082732 9,5.98461538 L9,6.01538462 C9,6.55917268 9.44771525,7 10,7 C10.5522847,7 11,6.55917268 11,6.01538462 L11,5.98461538 C11,5.44082732 10.5522847,5 10,5 Z'/%3E%3C/svg%3E%0A"); +} + +.info2 { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15' viewBox='0 0 15 15'%3E%3Cpath fill='%23999' d='M7.5,15 C3.35786438,15 0,11.6421356 0,7.5 C0,3.35786438 3.35786438,0 7.5,0 C11.6421356,0 15,3.35786438 15,7.5 C15,11.6421356 11.6421356,15 7.5,15 Z M7.5,2 C6.67157287,2 6,2.66124098 6,3.47692307 L6,3.52307693 C6,4.33875902 6.67157287,5 7.5,5 C8.32842705,5 9,4.33875902 9,3.52307693 L9,3.47692307 C9,2.66124098 8.32842705,2 7.5,2 Z M5,6 L5,7.02155172 L6,7 L6,12 L5,12.0076778 L5,13 L10,13 L10,12 L9,12.0076778 L9,6 L5,6 Z'/%3E%3C/svg%3E%0A"); +} + +.sdpi-more-info { + background-image: linear-gradient(to right, #00000000 0%,#00000040 80%), url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpolygon fill='%23999' points='4 7 8 7 8 5 12 8 8 11 8 9 4 9'/%3E%3C/svg%3E%0A"); +} +.caution { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath fill='%23999' fill-rule='evenodd' d='M9.03952676,0.746646542 C9.57068894,-0.245797319 10.4285735,-0.25196227 10.9630352,0.746646542 L19.7705903,17.2030214 C20.3017525,18.1954653 19.8777595,19 18.8371387,19 L1.16542323,19 C0.118729947,19 -0.302490098,18.2016302 0.231971607,17.2030214 L9.03952676,0.746646542 Z M10,2.25584053 L1.9601405,17.3478261 L18.04099,17.3478261 L10,2.25584053 Z M10,5.9375 C10.531043,5.9375 10.9615385,6.37373537 10.9615385,6.91185897 L10.9615385,11.6923077 C10.9615385,12.2304313 10.531043,12.6666667 10,12.6666667 C9.46895697,12.6666667 9.03846154,12.2304313 9.03846154,11.6923077 L9.03846154,6.91185897 C9.03846154,6.37373537 9.46895697,5.9375 10,5.9375 Z M10,13.4583333 C10.6372516,13.4583333 11.1538462,13.9818158 11.1538462,14.6275641 L11.1538462,14.6641026 C11.1538462,15.3098509 10.6372516,15.8333333 10,15.8333333 C9.36274837,15.8333333 8.84615385,15.3098509 8.84615385,14.6641026 L8.84615385,14.6275641 C8.84615385,13.9818158 9.36274837,13.4583333 10,13.4583333 Z'/%3E%3C/svg%3E%0A"); +} + +.question { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath fill='%23999' d='M10,18 C5.581722,18 2,14.418278 2,10 C2,5.581722 5.581722,2 10,2 C14.418278,2 18,5.581722 18,10 C18,14.418278 14.418278,18 10,18 Z M6.77783203,7.65332031 C6.77783203,7.84798274 6.85929281,8.02888914 7.0222168,8.19604492 C7.18514079,8.36320071 7.38508996,8.44677734 7.62207031,8.44677734 C8.02409055,8.44677734 8.29703704,8.20768468 8.44091797,7.72949219 C8.59326248,7.27245865 8.77945854,6.92651485 8.99951172,6.69165039 C9.2195649,6.45678594 9.56233491,6.33935547 10.027832,6.33935547 C10.4256205,6.33935547 10.7006836,6.37695313 11.0021973,6.68847656 C11.652832,7.53271484 10.942627,8.472229 10.3750916,9.1321106 C9.80755615,9.79199219 8.29492188,11.9897461 10.027832,12.1347656 C10.4498423,12.1700818 10.7027991,11.9147157 10.7832031,11.4746094 C11.0021973,9.59857178 13.1254883,8.82415771 13.1254883,7.53271484 C13.1254883,7.07568131 12.9974785,6.65250846 12.7414551,6.26318359 C12.4854317,5.87385873 12.1225609,5.56600048 11.652832,5.33959961 C11.1831031,5.11319874 10.6414419,5 10.027832,5 C9.36767248,5 8.79004154,5.13541531 8.29492187,5.40625 C7.79980221,5.67708469 7.42317837,6.01879677 7.16503906,6.43139648 C6.90689975,6.8439962 6.77783203,7.25130007 6.77783203,7.65332031 Z M10.0099668,15 C10.2713191,15 10.5016601,14.9108147 10.7009967,14.7324415 C10.9003332,14.5540682 11,14.3088087 11,13.9966555 C11,13.7157177 10.9047629,13.4793767 10.7142857,13.2876254 C10.5238086,13.0958742 10.2890379,13 10.0099668,13 C9.72646591,13 9.48726565,13.0958742 9.2923588,13.2876254 C9.09745196,13.4793767 9,13.7157177 9,13.9966555 C9,14.313268 9.10077419,14.5596424 9.30232558,14.735786 C9.50387698,14.9119295 9.73975502,15 10.0099668,15 Z'/%3E%3C/svg%3E%0A"); +} + + +.sdpi-more-info { + position: fixed; + left: 0px; + right: 0px; + bottom: 0px; + min-height:16px; + padding-right: 16px; + text-align: right; + -webkit-touch-callout: none; + cursor: pointer; + user-select: none; + background-position: right center; + background-repeat: no-repeat; + border-radius: var(--sdpi-borderradius); + text-decoration: none; + color: var(--sdpi-color); +} + +.sdpi-bottom-bar { + display: flex; + align-self: right; + margin-left: auto; + position: fixed; + right: 17px; + bottom: 0px; + user-select: none; +} + +.sdpi-bottom-bar.right { + right: 0px; +} + +.sdpi-bottom-bar button { + min-height: 20px !important; + height: 20px !important; +} + + +.sdpi-more-info-button { + display: flex; + align-self: right; + margin-left: auto; + position: fixed; + right: 17px; + bottom: 0px; + user-select: none; +} + +details a { + background-position: right !important; + min-height: 24px; + display: inline-block; + line-height: 24px; + padding-right: 28px; +} + + +input:not([type="range"]), +textarea { + -webkit-appearance: none; + background: var(--sdpi-background); + color: var(--sdpi-color); + font-weight: normal; + font-size: 9pt; + border: none; + margin-top: 2px; + margin-bottom: 2px; + /*min-width: 219px;*/ +} + +textarea + label { + display: flex; + justify-content: flex-end +} +input[type="radio"], +input[type="checkbox"] { + display: none; +} +input[type="radio"] + label, +input[type="checkbox"] + label { + font-size: 9pt; + color: var(--sdpi-color); + font-weight: normal; + margin-right: 8px; + -webkit-user-select: none; +} + +input[type="radio"] + label:after, +input[type="checkbox"] + label:after { + content: " " !important; +} + +.sdpi-item[type="radio"] > .sdpi-item-value, +.sdpi-item[type="checkbox"] > .sdpi-item-value { + padding-top: 2px; +} + +.sdpi-item[type="checkbox"] > .sdpi-item-value > * { + margin-top: 4px; +} + +.sdpi-item[type="checkbox"] .sdpi-item-child, +.sdpi-item[type="radio"] .sdpi-item-child { + display: inline-block; +} + +.sdpi-item[type="range"] .sdpi-item-value, +.sdpi-item[type="meter"] .sdpi-item-child, +.sdpi-item[type="progress"] .sdpi-item-child { + display: flex; +} + +.sdpi-item[type="range"] .sdpi-item-value { + min-height: 26px; +} + +.sdpi-item[type="range"] .sdpi-item-value span, +.sdpi-item[type="meter"] .sdpi-item-child span, +.sdpi-item[type="progress"] .sdpi-item-child span { + margin-top: -2px; + min-width: 8px; + text-align: right; + user-select: none; + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.sdpi-item[type="range"] .sdpi-item-value span { + margin-top: 7px; + text-align: right; +} + +span + input[type="range"] { + display: flex; + max-width: 168px; + +} + +.sdpi-item[type="range"] .sdpi-item-value span:first-child, +.sdpi-item[type="meter"] .sdpi-item-child span:first-child, +.sdpi-item[type="progress"] .sdpi-item-child span:first-child { + margin-right: 4px; +} + +.sdpi-item[type="range"] .sdpi-item-value span:last-child, +.sdpi-item[type="meter"] .sdpi-item-child span:last-child, +.sdpi-item[type="progress"] .sdpi-item-child span:last-child { + margin-left: 4px; +} + +.reverse { + transform: rotate(180deg); +} + +.sdpi-item[type="meter"] .sdpi-item-child meter + span:last-child { + margin-left: -10px; +} + +.sdpi-item[type="progress"] .sdpi-item-child meter + span:last-child { + margin-left: -14px; +} + +.sdpi-item[type="radio"] > .sdpi-item-value > * { + margin-top: 2px; +} + +details { + padding: 8px 18px 8px 12px; + min-width: 86px; +} + +details > h4 { + border-bottom: 1px solid var(--sdpi-bordercolor); +} + +legend { + display: none; +} +.sdpi-item-value > textarea { + padding: 0px; + width: 219px; + margin-left: 1px; + margin-top: 3px; + padding: 4px; +} + +input[type="radio"] + label span, +input[type="checkbox"] + label span { + display: inline-block; + width: 16px; + height: 16px; + margin: 2px 4px 2px 0; + border-radius: 3px; + vertical-align: middle; + background: var(--sdpi-background); + cursor: pointer; + border: 1px solid rgb(0,0,0,.2); +} + +input[type="radio"] + label span { + border-radius: 100%; +} + +input[type="radio"]:checked + label span, +input[type="checkbox"]:checked + label span { + background-color: #77f; + background-image: url(check.svg); + background-repeat: no-repeat; + background-position: center center; + border: 1px solid rgb(0,0,0,.4); +} + +input[type="radio"]:active:checked + label span, +input[type="radio"]:active + label span, +input[type="checkbox"]:active:checked + label span, +input[type="checkbox"]:active + label span { + background-color: #303030; +} + +input[type="radio"]:checked + label span { + background-image: url(rcheck.svg); +} + +input[type="range"] { + width: var(--sdpi-width); + height: 30px; + overflow: hidden; + cursor: pointer; + background: transparent !important; +} + +.sdpi-item > input[type="range"] { + margin-left: 2px; + max-width: var(--sdpi-width); + width: var(--sdpi-width); + padding: 0px; + margin-top: 2px; +} + +/* +input[type="range"], +input[type="range"]::-webkit-slider-runnable-track, +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; +} +*/ + +input[type="range"]::-webkit-slider-runnable-track { + height: 5px; + background: #979797; + border-radius: 3px; + padding:0px !important; + border: 1px solid var(--sdpi-background); +} + +input[type="range"]::-webkit-slider-thumb { + position: relative; + background-color: var(--sdpi-color); + width: 12px; + height: 12px; + border-radius: 20px; + margin-top: -5px; + border: none; +} +input[type="range" i]{ + margin: 0; +} + +input[type="range"]::-webkit-slider-thumb::before { + position: absolute; + content: ""; + height: 5px; /* equal to height of runnable track or 1 less */ + width: 500px; /* make this bigger than the widest range input element */ + left: -502px; /* this should be -2px - width */ + top: 8px; /* don't change this */ + background: #77f; +} + +input[type="color"] { + min-width: 32px; + min-height: 32px; + width: 32px; + height: 32px; + padding: 0; + background-color: var(--sdpi-bgcolor); + flex: none; +} + +::-webkit-color-swatch { + min-width: 24px; +} + +textarea { + height: 3em; + word-break: break-word; + line-height: 1.5em; +} + +.textarea { + padding: 0px !important; +} + +textarea { + width: 219px; /*98%;*/ + height: 96%; + min-height: 6em; + resize: none; + border-radius: var(--sdpi-borderradius); +} + +/* CAROUSEL */ + +.sdpi-item[type="carousel"]{ + +} + +.sdpi-item.card-carousel-wrapper, +.sdpi-item > .card-carousel-wrapper { + padding: 0; +} + + +.card-carousel-wrapper { + display: flex; + align-items: center; + justify-content: center; + margin: 12px auto; + color: #666a73; +} + +.card-carousel { + display: flex; + justify-content: center; + width: 278px; +} +.card-carousel--overflow-container { + overflow: hidden; +} +.card-carousel--nav__left, +.card-carousel--nav__right { + /* display: inline-block; */ + width: 12px; + height: 12px; + border-top: 2px solid #42b883; + border-right: 2px solid #42b883; + cursor: pointer; + margin: 0 4px; + transition: transform 150ms linear; +} +.card-carousel--nav__left[disabled], +.card-carousel--nav__right[disabled] { + opacity: 0.2; + border-color: black; +} +.card-carousel--nav__left { + transform: rotate(-135deg); +} +.card-carousel--nav__left:active { + transform: rotate(-135deg) scale(0.85); +} +.card-carousel--nav__right { + transform: rotate(45deg); +} +.card-carousel--nav__right:active { + transform: rotate(45deg) scale(0.85); +} +.card-carousel-cards { + display: flex; + transition: transform 150ms ease-out; + transform: translatex(0px); +} +.card-carousel-cards .card-carousel--card { + margin: 0 5px; + cursor: pointer; + /* box-shadow: 0 4px 15px 0 rgba(40, 44, 53, 0.06), 0 2px 2px 0 rgba(40, 44, 53, 0.08); */ + background-color: #fff; + border-radius: 4px; + z-index: 3; +} +.xxcard-carousel-cards .card-carousel--card:first-child { + margin-left: 0; +} +.xxcard-carousel-cards .card-carousel--card:last-child { + margin-right: 0; +} +.card-carousel-cards .card-carousel--card img { + vertical-align: bottom; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + transition: opacity 150ms linear; + width: 60px; +} +.card-carousel-cards .card-carousel--card img:hover { + opacity: 0.5; +} +.card-carousel-cards .card-carousel--card--footer { + border-top: 0; + max-width: 80px; + overflow: hidden; + display: flex; + height: 100%; + flex-direction: column; +} +.card-carousel-cards .card-carousel--card--footer p { + padding: 3px 0; + margin: 0; + margin-bottom: 2px; + font-size: 15px; + font-weight: 500; + color: #2c3e50; +} +.card-carousel-cards .card-carousel--card--footer p:nth-of-type(2) { + font-size: 12px; + font-weight: 300; + padding: 6px; + color: #666a73; +} + + +h1 { + font-size: 1.3em; + font-weight: 500; + text-align: center; + margin-bottom: 12px; +} + +::-webkit-datetime-edit { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + background: url(elg_calendar_inv.svg) no-repeat left center; + padding-right: 1em; + padding-left: 25px; + background-position: 4px 0px; + } +::-webkit-datetime-edit-fields-wrapper { + + } +::-webkit-datetime-edit-text { padding: 0 0.3em; } +::-webkit-datetime-edit-month-field { } +::-webkit-datetime-edit-day-field {} +::-webkit-datetime-edit-year-field {} +::-webkit-inner-spin-button { + + /* display: none; */ + } +::-webkit-calendar-picker-indicator { + background: transparent; + font-size: 17px; +} + +::-webkit-calendar-picker-indicator:focus { + background-color: rgba(0,0,0,0.2); +} + +input[type="date"] { + -webkit-align-items: center; + display: -webkit-inline-flex; + font-family: monospace; + overflow: hidden; + padding: 0; + -webkit-padding-start: 1px; +} + +input::-webkit-datetime-edit { + -webkit-flex: 1; + -webkit-user-modify: read-only !important; + display: inline-block; + min-width: 0; + overflow: hidden; +} + +/* +input::-webkit-datetime-edit-fields-wrapper { + -webkit-user-modify: read-only !important; + display: inline-block; + padding: 1px 0; + white-space: pre; + +} +*/ + +/* +input[type="date"] { + background-color: red; + outline: none; +} + +input[type="date"]::-webkit-clear-button { + font-size: 18px; + height: 30px; + position: relative; +} + +input[type="date"]::-webkit-inner-spin-button { + height: 28px; +} + +input[type="date"]::-webkit-calendar-picker-indicator { + font-size: 15px; +} */ + +input[type="file"] { + opacity: 0; + display: none; +} + +.sdpi-item > input[type="file"] { + opacity: 1; + display: flex; +} + +input[type="file"] + span { + display: flex; + flex: 0 1 auto; + background-color: #0000ff50; +} + +label.sdpi-file-label { + cursor: pointer; + user-select: none; + display: inline-block; + min-height: 21px !important; + height: 21px !important; + line-height: 20px; + padding: 0px 4px; + margin: auto; + margin-right: 0px; + float:right; +} + +.sdpi-file-label > label:active, +.sdpi-file-label.file:active, +label.sdpi-file-label:active, +label.sdpi-file-info:active, +input[type="file"]::-webkit-file-upload-button:active, +button:active { + background-color: var(--sdpi-color); + color:#303030; +} + +input:required:invalid, input:focus:invalid { + background: var(--sdpi-background) url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHZpZXdCb3g9IjAgMCA5IDkiPgogICAgPHBhdGggZmlsbD0iI0Q4RDhEOCIgZD0iTTQuNSwwIEM2Ljk4NTI4MTM3LC00LjU2NTM4NzgyZS0xNiA5LDIuMDE0NzE4NjMgOSw0LjUgQzksNi45ODUyODEzNyA2Ljk4NTI4MTM3LDkgNC41LDkgQzIuMDE0NzE4NjMsOSAzLjA0MzU5MTg4ZS0xNiw2Ljk4NTI4MTM3IDAsNC41IEMtMy4wNDM1OTE4OGUtMTYsMi4wMTQ3MTg2MyAyLjAxNDcxODYzLDQuNTY1Mzg3ODJlLTE2IDQuNSwwIFogTTQsMSBMNCw2IEw1LDYgTDUsMSBMNCwxIFogTTQuNSw4IEM0Ljc3NjE0MjM3LDggNSw3Ljc3NjE0MjM3IDUsNy41IEM1LDcuMjIzODU3NjMgNC43NzYxNDIzNyw3IDQuNSw3IEM0LjIyMzg1NzYzLDcgNCw3LjIyMzg1NzYzIDQsNy41IEM0LDcuNzc2MTQyMzcgNC4yMjM4NTc2Myw4IDQuNSw4IFoiLz4KICA8L3N2Zz4) no-repeat 98% center; +} + +input:required:valid { + background: var(--sdpi-background) url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5IiBoZWlnaHQ9IjkiIHZpZXdCb3g9IjAgMCA5IDkiPjxwb2x5Z29uIGZpbGw9IiNEOEQ4RDgiIHBvaW50cz0iNS4yIDEgNi4yIDEgNi4yIDcgMy4yIDcgMy4yIDYgNS4yIDYiIHRyYW5zZm9ybT0icm90YXRlKDQwIDQuNjc3IDQpIi8+PC9zdmc+) no-repeat 98% center; +} + +.tooltip, +:tooltip, +:title { + color: yellow; +} +/* +[title]:hover { + display: flex; + align-items: center; + justify-content: center; +} + +[title]:hover::after { + content: ''; + position: absolute; + bottom: -1000px; + left: 8px; + display: none; + color: #fff; + border: 8px solid transparent; + border-bottom: 8px solid #000; +} + +[title]:hover::before { + content: attr(title); + display: flex; + justify-content: center; + align-self: center; + padding: 6px 12px; + border-radius: 5px; + background: rgba(0,0,0,0.8); + color: var(--sdpi-color); + font-size: 9pt; + font-family: sans-serif; + opacity: 1; + position: absolute; + height: auto; + + text-align: center; + bottom: 2px; + z-index: 100; + box-shadow: 0px 3px 6px rgba(0, 0, 0, .5); +} +*/ + +.sdpi-item-group.file { + width: 232px; + display: flex; + align-items: center; +} + +.sdpi-file-info { + overflow-wrap: break-word; + word-wrap: break-word; + hyphens: auto; + + min-width: 132px; + max-width: 144px; + max-height: 32px; + margin-top: 0px; + margin-left: 5px; + display: inline-block; + overflow: hidden; + padding: 6px 4px; + background-color: var(--sdpi-background); +} + + +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); +} + +::-webkit-scrollbar-thumb { + background-color: #999999; + outline: 1px solid slategrey; + border-radius: 8px; +} + +a { + color: #7397d2; +} + +.testcontainer { + display: flex; + background-color: #0000ff20; + max-width: 400px; + height: 200px; + align-content: space-evenly; +} + +input[type=range] { + /* background-color: green; */ + height:6px; + margin-top: 12px; + z-index: 0; + overflow: visible; +} + +/* +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + background-color: var(--sdpi-color); + width: 12px; + height: 12px; + border-radius: 20px; + margin-top: -6px; + border: none; +} */ + +:-webkit-slider-thumb { + -webkit-appearance: none; + background-color: var(--sdpi-color); + width: 16px; + height: 16px; + border-radius: 20px; + margin-top: -6px; + border: 1px solid #999999; +} + +.sdpi-item[type="range"] .sdpi-item-group { + display: flex; + flex-direction: column; +} + +.xxsdpi-item[type="range"] .sdpi-item-group input { + max-width: 204px; +} + +.sdpi-item[type="range"] .sdpi-item-group span { + margin-left: 0px !important; +} + +.sdpi-item[type="range"] .sdpi-item-group > .sdpi-item-child { + display: flex; + flex-direction: row; +} + +.rangeLabel { + position:absolute; + font-weight:normal; + margin-top:22px; +} + +:disabled { + color: #993333; +} + +select, +select option { + color: var(--sdpi-color); +} + +select.disabled, +select option:disabled { + color: #fd9494; + font-style: italic; +} + +.runningAppsContainer { + display: none; +} + +/* debug +div { + background-color: rgba(64,128,255,0.2); +} +*/ + +.one-line { + min-height: 1.5em; +} + +.two-lines { + min-height: 3em; +} + +.three-lines { + min-height: 4.5em; +} + +.four-lines { + min-height: 6em; +} + +.min80 > .sdpi-item-child { + min-width: 80px; +} + +.min100 > .sdpi-item-child { + min-width: 100px; +} + +.min120 > .sdpi-item-child { + min-width: 120px; +} + +.min140 > .sdpi-item-child { + min-width: 140px; +} + +.min160 > .sdpi-item-child { + min-width: 160px; +} + +.min200 > .sdpi-item-child { + min-width: 200px; +} + +.max40 { + flex-basis: 40%; + flex-grow: 0; +} + +.max30 { + flex-basis: 30%; + flex-grow: 0; +} + +.max20 { + flex-basis: 20%; + flex-grow: 0; +} + +.up20 { + margin-top: -20px; +} + +.alignCenter { + align-items: center; +} + +.alignTop { + align-items: flex-start; +} + +.alignBaseline { + align-items: baseline; +} + +.noMargins, +.noMargins *, +.noInnerMargins * { + margin: 0; + padding: 0; +} + +.hidden { + display: none; +} + +.icon-help, +.icon-help-line, +.icon-help-fill, +.icon-help-inv, +.icon-brighter, +.icon-darker, +.icon-warmer, +.icon-cooler { + min-width: 20px; + width: 20px; + background-repeat: no-repeat; + opacity: 1; +} + +.icon-help:active, +.icon-help-line:active, +.icon-help-fill:active, +.icon-help-inv:active, +.icon-brighter:active, +.icon-darker:active, +.icon-warmer:active, +.icon-cooler:active { + opacity: 0.5; +} + +.icon-brighter, +.icon-darker, +.icon-warmer, +.icon-cooler { + margin-top: 5px !important; +} + +.icon-help, +.icon-help-line, +.icon-help-fill, +.icon-help-inv { + cursor: pointer; + margin: 0px; + margin-left: 4px; +} + +.icon-brighter { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='%23999' fill-rule='evenodd'%3E%3Ccircle cx='10' cy='10' r='4'/%3E%3Cpath d='M14.8532861,7.77530426 C14.7173255,7.4682615 14.5540843,7.17599221 14.3666368,6.90157083 L16.6782032,5.5669873 L17.1782032,6.4330127 L14.8532861,7.77530426 Z M10.5,4.5414007 C10.2777625,4.51407201 10.051423,4.5 9.82179677,4.5 C9.71377555,4.5 9.60648167,4.50311409 9.5,4.50925739 L9.5,2 L10.5,2 L10.5,4.5414007 Z M5.38028092,6.75545367 C5.18389364,7.02383457 5.01124349,7.31068015 4.86542112,7.61289977 L2.82179677,6.4330127 L3.32179677,5.5669873 L5.38028092,6.75545367 Z M4.86542112,12.3871002 C5.01124349,12.6893198 5.18389364,12.9761654 5.38028092,13.2445463 L3.32179677,14.4330127 L2.82179677,13.5669873 L4.86542112,12.3871002 Z M9.5,15.4907426 C9.60648167,15.4968859 9.71377555,15.5 9.82179677,15.5 C10.051423,15.5 10.2777625,15.485928 10.5,15.4585993 L10.5,18 L9.5,18 L9.5,15.4907426 Z M14.3666368,13.0984292 C14.5540843,12.8240078 14.7173255,12.5317385 14.8532861,12.2246957 L17.1782032,13.5669873 L16.6782032,14.4330127 L14.3666368,13.0984292 Z'/%3E%3C/g%3E%3C/svg%3E"); +} +.icon-darker { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='%23999' fill-rule='evenodd'%3E%3Cpath d='M10 14C7.790861 14 6 12.209139 6 10 6 7.790861 7.790861 6 10 6 12.209139 6 14 7.790861 14 10 14 12.209139 12.209139 14 10 14zM10 13C11.6568542 13 13 11.6568542 13 10 13 8.34314575 11.6568542 7 10 7 8.34314575 7 7 8.34314575 7 10 7 11.6568542 8.34314575 13 10 13zM14.8532861 7.77530426C14.7173255 7.4682615 14.5540843 7.17599221 14.3666368 6.90157083L16.6782032 5.5669873 17.1782032 6.4330127 14.8532861 7.77530426zM10.5 4.5414007C10.2777625 4.51407201 10.051423 4.5 9.82179677 4.5 9.71377555 4.5 9.60648167 4.50311409 9.5 4.50925739L9.5 2 10.5 2 10.5 4.5414007zM5.38028092 6.75545367C5.18389364 7.02383457 5.01124349 7.31068015 4.86542112 7.61289977L2.82179677 6.4330127 3.32179677 5.5669873 5.38028092 6.75545367zM4.86542112 12.3871002C5.01124349 12.6893198 5.18389364 12.9761654 5.38028092 13.2445463L3.32179677 14.4330127 2.82179677 13.5669873 4.86542112 12.3871002zM9.5 15.4907426C9.60648167 15.4968859 9.71377555 15.5 9.82179677 15.5 10.051423 15.5 10.2777625 15.485928 10.5 15.4585993L10.5 18 9.5 18 9.5 15.4907426zM14.3666368 13.0984292C14.5540843 12.8240078 14.7173255 12.5317385 14.8532861 12.2246957L17.1782032 13.5669873 16.6782032 14.4330127 14.3666368 13.0984292z'/%3E%3C/g%3E%3C/svg%3E"); +} +.icon-warmer { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='%23999' fill-rule='evenodd'%3E%3Cpath d='M12.3247275 11.4890349C12.0406216 11.0007637 11.6761954 10.5649925 11.2495475 10.1998198 11.0890394 9.83238991 11 9.42659309 11 9 11 7.34314575 12.3431458 6 14 6 15.6568542 6 17 7.34314575 17 9 17 10.6568542 15.6568542 12 14 12 13.3795687 12 12.8031265 11.8116603 12.3247275 11.4890349zM17.6232392 11.6692284C17.8205899 11.4017892 17.9890383 11.1117186 18.123974 10.8036272L20.3121778 12.0669873 19.8121778 12.9330127 17.6232392 11.6692284zM18.123974 7.19637279C17.9890383 6.88828142 17.8205899 6.5982108 17.6232392 6.33077158L19.8121778 5.0669873 20.3121778 5.9330127 18.123974 7.19637279zM14.5 4.52746439C14.3358331 4.50931666 14.1690045 4.5 14 4.5 13.8309955 4.5 13.6641669 4.50931666 13.5 4.52746439L13.5 2 14.5 2 14.5 4.52746439zM13.5 13.4725356C13.6641669 13.4906833 13.8309955 13.5 14 13.5 14.1690045 13.5 14.3358331 13.4906833 14.5 13.4725356L14.5 16 13.5 16 13.5 13.4725356zM14 11C15.1045695 11 16 10.1045695 16 9 16 7.8954305 15.1045695 7 14 7 12.8954305 7 12 7.8954305 12 9 12 10.1045695 12.8954305 11 14 11zM9.5 11C10.6651924 11.4118364 11.5 12.5 11.5 14 11.5 16 10 17.5 8 17.5 6 17.5 4.5 16 4.5 14 4.5 12.6937812 5 11.5 6.5 11L6.5 7 9.5 7 9.5 11z'/%3E%3Cpath d='M12,14 C12,16.209139 10.209139,18 8,18 C5.790861,18 4,16.209139 4,14 C4,12.5194353 4.80439726,11.2267476 6,10.5351288 L6,4 C6,2.8954305 6.8954305,2 8,2 C9.1045695,2 10,2.8954305 10,4 L10,10.5351288 C11.1956027,11.2267476 12,12.5194353 12,14 Z M11,14 C11,12.6937812 10.1651924,11.5825421 9,11.1707057 L9,4 C9,3.44771525 8.55228475,3 8,3 C7.44771525,3 7,3.44771525 7,4 L7,11.1707057 C5.83480763,11.5825421 5,12.6937812 5,14 C5,15.6568542 6.34314575,17 8,17 C9.65685425,17 11,15.6568542 11,14 Z'/%3E%3C/g%3E%3C/svg%3E"); +} + +.icon-cooler { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='%23999' fill-rule='evenodd'%3E%3Cpath d='M10.4004569 11.6239517C10.0554735 10.9863849 9.57597206 10.4322632 9 9.99963381L9 9.7450467 9.53471338 9.7450467 10.8155381 8.46422201C10.7766941 8.39376637 10.7419749 8.32071759 10.7117062 8.2454012L9 8.2454012 9 6.96057868 10.6417702 6.96057868C10.6677696 6.86753378 10.7003289 6.77722682 10.7389179 6.69018783L9.44918707 5.40045694 9 5.40045694 9 4.34532219 9.32816127 4.34532219 9.34532219 2.91912025 10.4004569 2.91912025 10.4004569 4.53471338 11.6098599 5.74411634C11.7208059 5.68343597 11.8381332 5.63296451 11.9605787 5.59396526L11.9605787 3.8884898 10.8181818 2.74609294 11.5642748 2 12.5727518 3.00847706 13.5812289 2 14.3273218 2.74609294 13.2454012 3.82801356 13.2454012 5.61756719C13.3449693 5.65339299 13.4408747 5.69689391 13.5324038 5.74735625L14.7450467 4.53471338 14.7450467 2.91912025 15.8001815 2.91912025 15.8001815 4.34532219 17.2263834 4.34532219 17.2263834 5.40045694 15.6963166 5.40045694 14.4002441 6.69652946C14.437611 6.78161093 14.4692249 6.86979146 14.4945934 6.96057868L16.2570138 6.96057868 17.3994107 5.81818182 18.1455036 6.56427476 17.1370266 7.57275182 18.1455036 8.58122888 17.3994107 9.32732182 16.3174901 8.2454012 14.4246574 8.2454012C14.3952328 8.31861737 14.3616024 8.38969062 14.3240655 8.45832192L15.6107903 9.7450467 17.2263834 9.7450467 17.2263834 10.8001815 15.8001815 10.8001815 15.8001815 12.2263834 14.7450467 12.2263834 14.7450467 10.6963166 13.377994 9.32926387C13.3345872 9.34850842 13.2903677 9.36625331 13.2454012 9.38243281L13.2454012 11.3174901 14.3273218 12.3994107 13.5812289 13.1455036 12.5848864 12.1491612 11.5642748 13.1455036 10.8181818 12.3994107 11.9605787 11.2570138 11.9605787 9.40603474C11.8936938 9.38473169 11.828336 9.36000556 11.7647113 9.33206224L10.4004569 10.6963166 10.4004569 11.6239517zM12.75 8.5C13.3022847 8.5 13.75 8.05228475 13.75 7.5 13.75 6.94771525 13.3022847 6.5 12.75 6.5 12.1977153 6.5 11.75 6.94771525 11.75 7.5 11.75 8.05228475 12.1977153 8.5 12.75 8.5zM9.5 14C8.5 16.3333333 7.33333333 17.5 6 17.5 4.66666667 17.5 3.5 16.3333333 2.5 14L9.5 14z'/%3E%3Cpath d='M10,14 C10,16.209139 8.209139,18 6,18 C3.790861,18 2,16.209139 2,14 C2,12.5194353 2.80439726,11.2267476 4,10.5351288 L4,4 C4,2.8954305 4.8954305,2 6,2 C7.1045695,2 8,2.8954305 8,4 L8,10.5351288 C9.19560274,11.2267476 10,12.5194353 10,14 Z M9,14 C9,12.6937812 8.16519237,11.5825421 7,11.1707057 L7,4 C7,3.44771525 6.55228475,3 6,3 C5.44771525,3 5,3.44771525 5,4 L5,11.1707057 C3.83480763,11.5825421 3,12.6937812 3,14 C3,15.6568542 4.34314575,17 6,17 C7.65685425,17 9,15.6568542 9,14 Z'/%3E%3C/g%3E%3C/svg%3E"); +} + +.icon-help { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath fill='%23999' d='M11.292 12.516l.022 1.782H9.07v-1.804c0-1.98 1.276-2.574 2.662-3.278h-.022c.814-.44 1.65-.88 1.694-2.2.044-1.386-1.122-2.728-3.234-2.728-1.518 0-2.662.902-3.366 2.354L5 5.608C5.946 3.584 7.662 2 10.17 2c3.564 0 5.632 2.442 5.588 5.06-.066 2.618-1.716 3.41-3.102 4.158-.704.374-1.364.682-1.364 1.298zm-1.122 2.442c.858 0 1.452.594 1.452 1.452 0 .682-.594 1.408-1.452 1.408-.77 0-1.386-.726-1.386-1.408 0-.858.616-1.452 1.386-1.452z'/%3E%3C/svg%3E"); +} + +.icon-help-line { + background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23999' fill-rule='evenodd'%3E%3Cpath d='M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zm0-1a9 9 0 1 0 0-18 9 9 0 0 0 0 18z'/%3E%3Cpath d='M10.848 12.307l.02 1.578H8.784v-1.597c0-1.753 1.186-2.278 2.474-2.901h-.02c.756-.39 1.533-.78 1.574-1.948.041-1.226-1.043-2.414-3.006-2.414-1.41 0-2.474.798-3.128 2.083L5 6.193C5.88 4.402 7.474 3 9.805 3 13.118 3 15.04 5.161 15 7.478c-.061 2.318-1.595 3.019-2.883 3.68-.654.332-1.268.604-1.268 1.15zM9.805 14.47c.798 0 1.35.525 1.35 1.285 0 .603-.552 1.246-1.35 1.246-.715 0-1.288-.643-1.288-1.246 0-.76.573-1.285 1.288-1.285z' fill-rule='nonzero'/%3E%3C/g%3E%3C/svg%3E");} + +.icon-help-fill { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Ccircle cx='10' cy='10' r='10' fill='%23999'/%3E%3Cpath fill='%23FFF' fill-rule='nonzero' d='M8.368 7.189H5C5 3.5 7.668 2 10.292 2 13.966 2 16 4.076 16 7.012c0 3.754-3.849 3.136-3.849 5.211v1.656H8.455v-1.832c0-2.164 1.4-2.893 2.778-3.6.437-.242 1.006-.574 1.006-1.236 0-2.208-3.871-2.142-3.871-.022zM10.25 18a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5z'/%3E%3C/g%3E%3C/svg%3E"); +} + +.icon-help-inv { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cpath fill='%23999' fill-rule='evenodd' d='M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zM8.368 7.189c0-2.12 3.87-2.186 3.87.022 0 .662-.568.994-1.005 1.236-1.378.707-2.778 1.436-2.778 3.6v1.832h3.696v-1.656c0-2.075 3.849-1.457 3.849-5.21C16 4.075 13.966 2 10.292 2 7.668 2 5 3.501 5 7.189h3.368zM10.25 18a1.75 1.75 0 1 0 0-3.5 1.75 1.75 0 0 0 0 3.5z'/%3E%3C/svg%3E"); +} + +.kelvin::after { + content: "K"; +} + +.mired::after { + content: " Mired"; +} + +.percent::after { + content: "%"; +} + +.sdpi-item-value + .icon-cooler, +.sdpi-item-value + .icon-warmer { + margin-left: 0px !important; + margin-top: 15px !important; +} + +/** + CONTROL-CENTER STYLES +*/ +input[type="range"].colorbrightness::-webkit-slider-runnable-track, +input[type="range"].colortemperature::-webkit-slider-runnable-track { + height: 8px; + background: #979797; + border-radius: 4px; + background-image: linear-gradient(to right,#94d0ec, #ffb165); +} + +input[type="range"].colorbrightness::-webkit-slider-runnable-track { + background-color: #efefef; + background-image: linear-gradient(to right, black , rgba(0,0,0,0)); +} + + +input[type="range"].colorbrightness::-webkit-slider-thumb, +input[type="range"].colortemperature::-webkit-slider-thumb { + width: 16px; + height: 16px; + border-radius: 20px; + margin-top: -5px; + background-color: #86c6e8; + box-shadow: 0px 0px 1px #000000; + border: 1px solid #d8d8d8; +} +.sdpi-info-label { + display: inline-block; + user-select: none; + position: absolute; + height: 15px; + width: auto; + text-align: center; + border-radius: 4px; + min-width: 44px; + max-width: 80px; + background: white; + font-size: 11px; + color: black; + z-index: 1000; + box-shadow: 0px 0px 12px rgba(0,0,0,.8); + padding: 2px; + +} + +.sdpi-info-label.hidden { + opacity: 0; + transition: opacity 0.25s linear; +} + +.sdpi-info-label.shown { + position: absolute; + opacity: 1; + transition: opacity 0.25s ease-out; +} + + + + +/*--------- context menu ----------*/ + +.context-menu { + display: none; + position: absolute; + z-index: 10; + padding: 12px 0; + width: 120px; + background-color: #3D3D3D; + border: solid 1px #dfdfdf; + box-shadow: 1px 1px 2px #cfcfcf; +} + +.context-menu--active { + display: block; +} + +.context-menu__items { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; +} + +.context-menu__item { + display: block; + margin-bottom: 4px; + background-color: #3D3D3D !important; +} + +.context-menu__item:last-child { + margin-bottom: 0; +} + +.context-menu__link { + display: block; + padding: 4px 12px; + color: #ffff; + text-decoration: none; + white-space: nowrap; +} + +.context-menu__link:hover { + color: #fff; + background-color: #0066aa; +} + +.context-menu_message{ + cursor: default; +} diff --git a/src/com.genericmale.sonos.sdPlugin/pi/form-utils.js b/src/com.genericmale.sonos.sdPlugin/pi/form-utils.js new file mode 100644 index 0000000..c8e85a8 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/form-utils.js @@ -0,0 +1,208 @@ +class FormUtils { + /** + * Returns the value from a form using the form controls name property + * @param {*} form + * @returns + */ + static getFormValue(form) { + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form?.elements; + + if (!elements) { + throw 'Could not find form!'; + } + + const formData = new FormData(form); + let formValue = {}; + + formData.forEach((value, key) => { + if (!Reflect.has(formValue, key)) { + formValue[key] = value; + return; + } + if (!Array.isArray(formValue[key])) { + formValue[key] = [formValue[key]]; + } + formValue[key].push(value); + }); + + return formValue; + } + + /** + * Sets the value of form controls using their name attribute and the jsn object key + * @param {*} jsn + * @param {*} form + */ + static setFormValue(jsn, form) { + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form?.elements; + + if (!elements) { + throw 'Could not find form!'; + } + + Array.from(elements) + .filter((element) => element?.name) + .forEach((element) => { + const {name, type} = element; + if (jsn.hasOwnProperty(name)) { + const value = jsn[name]; + const isCheckOrRadio = type === 'checkbox' || type === 'radio'; + if (isCheckOrRadio) { + const isSingle = value === element.value; + if (isSingle || (Array.isArray(value) && value.includes(element.value))) { + element.checked = true; + } + } else { + element.value = value ?? ''; + } + } + }); + } + + /** + * This provides a slight delay before processing rapid events + * @param {*} wait - delay before processing function (recommended time 150ms) + * @param {*} fn + * @returns + */ + static debounce(wait, fn) { + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + fn.apply(null, args); + }, wait); + }; + } + + /** + * Quick utility to lighten or darken a color (doesn't take color-drifting, etc. into account) + * Usage: + * fadeColor('#061261', 100); // will lighten the color + * fadeColor('#200867'), -100); // will darken the color + */ + static fadeColor(col, amt) { + const min = Math.min, max = Math.max; + const num = parseInt(col.replace(/#/g, ''), 16); + const r = min(255, max((num >> 16) + amt, 0)); + const g = min(255, max((num & 0x0000FF) + amt, 0)); + const b = min(255, max(((num >> 8) & 0x00FF) + amt, 0)); + return '#' + (g | (b << 8) | (r << 16)).toString(16).padStart(6, 0); + } + + /** + * Stream Deck software passes system-highlight color information + * to Property Inspector. Here we 'inject' the CSS styles into the DOM + * when we receive this information. + */ + static addDynamicStyles(clrs) { + const node = document.getElementById('#sdpi-dynamic-styles') || document.createElement('style'); + if (!clrs.mouseDownColor) clrs.mouseDownColor = this.fadeColor(clrs.highlightColor, -100); + const clr = clrs.highlightColor.slice(0, 7); + const clr1 = this.fadeColor(clr, 100); + const clr2 = this.fadeColor(clr, 60); + const metersActiveColor = this.fadeColor(clr, -60); + + node.setAttribute('id', 'sdpi-dynamic-styles'); + node.innerHTML = ` + + input[type="radio"]:checked + label span, + input[type="checkbox"]:checked + label span { + background-color: ${clrs.highlightColor}; + } + + input[type="radio"]:active:checked + label span, + input[type="radio"]:active + label span, + input[type="checkbox"]:active:checked + label span, + input[type="checkbox"]:active + label span { + background-color: ${clrs.mouseDownColor}; + } + + input[type="radio"]:active + label span, + input[type="checkbox"]:active + label span { + background-color: ${clrs.buttonPressedBorderColor}; + } + + td.selected, + td.selected:hover, + li.selected:hover, + li.selected { + color: white; + background-color: ${clrs.highlightColor}; + } + + .sdpi-file-label > label:active, + .sdpi-file-label.file:active, + label.sdpi-file-label:active, + label.sdpi-file-info:active, + input[type="file"]::-webkit-file-upload-button:active, + button:active { + background-color: ${clrs.buttonPressedBackgroundColor}; + color: ${clrs.buttonPressedTextColor}; + border-color: ${clrs.buttonPressedBorderColor}; + } + + ::-webkit-progress-value, + meter::-webkit-meter-optimum-value { + background: linear-gradient(${clr2}, ${clr1} 20%, ${clr} 45%, ${clr} 55%, ${clr2}) + } + + ::-webkit-progress-value:active, + meter::-webkit-meter-optimum-value:active { + background: linear-gradient(${clr}, ${clr2} 20%, ${metersActiveColor} 45%, ${metersActiveColor} 55%, ${clr}) + } + `; + document.body.appendChild(node); + } + + /** + * Fetches the specified language json file + * @param pathPrefix + * @returns {Promise} + */ + static async loadLocalization(language, pathPrefix) { + const manifest = await this.readJson(`${pathPrefix}${language}.json`); + const localization = manifest['Localization'] ?? null; + + if (localization) { + const elements = document.querySelectorAll('[data-localize]'); + + elements.forEach((element) => { + element.textContent = + localization[element.textContent] ?? element.textContent; + }); + } + } + + /** + * @private + */ + static async readJson(path) { + return new Promise((resolve, reject) => { + const req = new XMLHttpRequest(); + req.onerror = reject; + req.overrideMimeType('application/json'); + req.open('GET', path, true); + req.onreadystatechange = (response) => { + if (req.readyState === 4) { + const jsonString = response?.target?.response; + if (jsonString) { + resolve(JSON.parse(response?.target?.response)); + } else { + reject(); + } + } + }; + + req.send(); + }); + } +} diff --git a/src/com.genericmale.sonos.sdPlugin/pi/index.html b/src/com.genericmale.sonos.sdPlugin/pi/index.html new file mode 100644 index 0000000..e4ff05b --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/index.html @@ -0,0 +1,168 @@ + + + + + + + + + Sonos Property Inspector + + + + + +
+ +
+
+
Speaker IP
+ +
+ +
+
+ Obtaining Speaker IP Address +

Sonos Desktop App

+ Help -> About My Sonos System... +

Sonos Mobile App

+ Settings -> System -> About My System +
+
+
+ +
+ +
+
+
Refresh Interval
+ +
+ +
+
Volume
+ +
+ +
+
Volume Increase
+ +
+ +
+
Volume Decrease
+ +
+ +
+
URL
+ +
+ +
+
Source
+ +
+ +
+
Favorite
+ +
+ +
+ +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+
+ + + +
+
Top Text
+ +
+
+
Middle Text
+ +
+
+
Bottom Text
+ +
+ +
+
Paused Text
+ +
+ +
+
+ Supported Music Services +

Spotify

+

Tidal

+

Deezer

+

Apple Music

+

TuneIn

+
+
+
+
+ + + + + + + diff --git a/src/com.genericmale.sonos.sdPlugin/pi/index.js b/src/com.genericmale.sonos.sdPlugin/pi/index.js new file mode 100644 index 0000000..fb6f945 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/pi/index.js @@ -0,0 +1,76 @@ +/** + * This is the first function StreamDeck Software calls, when + * establishing the connection to the plugin or the Property Inspector + * @param {string} port - The socket's port to communicate with StreamDeck software. + * @param {string} pluginUUID - A unique identifier, which StreamDeck uses to communicate with the plugin + * @param {string} registerEvent - Identifies, if the event is meant for the property inspector or the plugin. + * @param {string} info - Information about the host (StreamDeck) application + * @param {string} actionInfo - Context is an internal identifier used to communicate to the host application. + */ +function connectElgatoStreamDeckSocket(port, pluginUUID, registerEvent, info, actionInfo) { + const globalSettingsForm = document.getElementById('global-settings'); + const settingsForm = document.getElementById('settings'); + const sonos = new Sonos(); + + const streamDeck = new StreamDeck(); + streamDeck.connect(port, pluginUUID, registerEvent, info, actionInfo); + streamDeck.onConnected(() => { + FormUtils.loadLocalization(streamDeck.appInfo.application.language ?? null, '../'); + FormUtils.addDynamicStyles(streamDeck.appInfo.colors); + + //use last part of uuid to check which inputs to show + const action = streamDeck.actionInfo.action.split('.').pop(); + + //show the items for the action + [...settingsForm.querySelectorAll('.sdpi-item')] + .filter((e) => e.dataset.actions.split(',').includes(action)) + .forEach((e) => e.classList.add('active')); + + //disable controls which aren't visible, so they don't get included in the FormData + settingsForm.querySelectorAll('.sdpi-item input,select,textarea') + .forEach((e) => e.disabled = e.closest('.sdpi-item.active') === null); + + //propagate form with persisted data + const settings = streamDeck.actionInfo.payload.settings; + FormUtils.setFormValue(settings, settingsForm); + + //watch for changes and store them + settingsForm.addEventListener( + 'input', + FormUtils.debounce(150, () => { + const value = FormUtils.getFormValue(settingsForm); + streamDeck.setSettings(value); + }) + ); + + streamDeck.getGlobalSettings().then((globalSettings) => { + //propagate form with persisted data + FormUtils.setFormValue(globalSettings, globalSettingsForm); + + //watch for changes and store them + globalSettingsForm.addEventListener( + 'input', + FormUtils.debounce(150, () => { + const value = FormUtils.getFormValue(globalSettingsForm); + streamDeck.setGlobalSettings(value); + }) + ); + + sonos.connect(globalSettings.host, parseInt(globalSettings.port) || 1400); + + if(action === 'playfavorites') { + sonos.browse(Sonos.BROWSE_TYPE.SONOS_FAVORITES).then((items) => { + const favorite = settings.favorite ? JSON.parse(settings.favorite) : null; + items.forEach((item) => { + const select = document.getElementById('favorites'); + const option = document.createElement('option'); + option.value = JSON.stringify(item); + option.selected = favorite && favorite.uri === item.uri; + option.innerHTML = item.title; + select.appendChild(option); + }); + }) + } + }); + }) +} diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/changesource.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/changesource.js new file mode 100644 index 0000000..426953b --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/changesource.js @@ -0,0 +1,14 @@ +define(class extends SonosAction { + async onKeyDown({payload: {settings}}) { + if (settings.source === 'tv') { + await this.sonos.setLocalTransport('x-sonos-htastream', ':spdif'); + } else if (settings.source === 'line_in') { + await this.sonos.setLocalTransport('x-rincon-stream'); + } else { + await this.sonos.setLocalTransport('x-rincon-queue', '#0'); + } + + if (settings.play === '1') + return this.sonos.play(); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/mute.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/mute.js new file mode 100644 index 0000000..47b0955 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/mute.js @@ -0,0 +1,12 @@ +define(class extends PollingAction { + async onKeyDown({payload: {state}}) { + return state === 0 ? + this.sonos.setMute(1) : + this.sonos.setMute(0); + } + + async refresh() { + const {CurrentMute: muted} = await this.sonos.getMute(); + return this.streamDeck.setState(muted === '1' ? 1 : 0, this.context); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/next.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/next.js new file mode 100644 index 0000000..f7cd6a9 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/next.js @@ -0,0 +1,5 @@ +define(class extends SonosAction { + async onKeyDown() { + return this.sonos.next(); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/playfavorites.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/playfavorites.js new file mode 100644 index 0000000..33157f3 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/playfavorites.js @@ -0,0 +1,24 @@ +define(class extends SonosAction { + constructor(streamDeck, action, context) { + super(streamDeck, action, context); + this.streamDeck.getSettings(this.context); + } + + async onKeyDown({payload: {settings}}) { + const favorite = JSON.parse(settings.favorite); + if (favorite) { + await this.sonos.setServiceURI(favorite.uri, favorite.metadata); + if (settings.play === '1') + return this.sonos.play(); + } + } + + async onDidReceiveSettings({payload: {settings}}) { + const favorite = JSON.parse(settings.favorite); + if(favorite && settings.showAlbumArt === '1') { + return this.streamDeck.setImageURL(favorite.albumArtURI, null, null, this.context); + } else { + return this.streamDeck.setImage(null, 0, null, this.context); + } + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/playpause.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/playpause.js new file mode 100644 index 0000000..f6af01c --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/playpause.js @@ -0,0 +1,81 @@ +define(class extends PollingAction { + timeRegex = /^\d?\d:\d\d:\d\d$/; + defaultTitleParameters = { + fontFamily: 'Arial', + fontSize: 10, + fontStyle: 'Bold', + fontUnderline: false, + showTitle: true, + titleColor: '#ffffff' + }; + + async onKeyDown({payload: {state}}) { + if (state === 0) { + await this.sonos.play(); + return this.drawState(1); + } else { + await this.sonos.pause(); + return this.drawState(0); + } + } + + async onTitleParametersDidChange(event) { + this.settings = event.payload.settings; + this.settings.titleParameters = event.payload.titleParameters; + this.streamDeck.setSettings(this.settings, event.context); + return this.refresh(); + } + + async refresh() { + const {CurrentTransportState: transportState} = await this.sonos.getTransportInfo(); + const state = transportState === 'PLAYING' ? 1 : 0; + this.streamDeck.setState(state, this.context); + await this.drawState(state) + } + + async drawState(state) { + const titleParameters = this.settings.titleParameters || this.defaultTitleParameters; + + if (this.settings.showAlbumArt !== '1') + return this.streamDeck.setImage(null, 0, null, this.context); + + let { + RelTime: elapsed, + TrackDuration: duration, + TrackMetaData: metadata + } = await this.sonos.getPositionInfo(); + const track = new DOMParser().parseFromString(metadata, 'text/xml'); + const albumArtURI = this.sonos.getAlbumArtURI(track); + if(!albumArtURI) + return this.streamDeck.setImage(null, 0, null, this.context); + + let texts; + if(titleParameters.showTitle) { + const artist = this.sonos.getElementText(track, 'dc:creator'); + const title = artist ? + this.sonos.getElementText(track, 'dc:title') : + this.sonos.getElementText(track, 'r:streamContent'); + + let remaining; + if (elapsed && this.timeRegex.test(elapsed) && duration && this.timeRegex.test(duration)) { + const elapsedSec = elapsed.split(':').reduce((p, c) => p * 60 + +c, 0); + const durationSec = duration.split(':').reduce((p, c) => p * 60 + +c, 0); + const remainingSec = durationSec - elapsedSec; + remaining = new Date(remainingSec * 1000).toISOString().substring(11, 19); + + duration = duration.replace(/^0+:/, ''); + elapsed = elapsed.replace(/^0+:/, ''); + remaining = remaining.replace(/^0+:/, ''); + } else { + duration = elapsed = remaining = null; + } + + const info = {artist, title, duration, elapsed, remaining}; + texts = state === 0 && this.settings.paused ? + {bottom: this.settings.paused} : + {top: info[this.settings.top], middle: info[this.settings.middle], bottom: info[this.settings.bottom]} + } + + return this.streamDeck.setImageURL(albumArtURI, texts, titleParameters, this.context); + } +}) diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/playuri.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/playuri.js new file mode 100644 index 0000000..3a2cd1d --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/playuri.js @@ -0,0 +1,12 @@ +define(class extends SonosAction { + async onKeyDown({payload: {settings}}) { + const service = MusicService.parse(settings.uri); + if (!service) + throw new Error(`Invalid media URI "${settings.uri}"`); + + await this.sonos.setServiceURI(service.uri, service.metadata); + + if (settings.play === '1') + return this.sonos.play(); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/previous.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/previous.js new file mode 100644 index 0000000..37b7367 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/previous.js @@ -0,0 +1,5 @@ +define(class extends SonosAction { + async onKeyDown() { + return this.sonos.previous() + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/repeat.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/repeat.js new file mode 100644 index 0000000..13eaa99 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/repeat.js @@ -0,0 +1,22 @@ +define(class extends PollingAction { + async onKeyDown({payload: {state}}) { + const {PlayMode: mode} = await this.sonos.getTransportSettings() + const shuffle = mode.indexOf('SHUFFLE') === 0; + if (state === 0) + return this.sonos.setPlayMode(shuffle ? 'SHUFFLE' : 'REPEAT_ALL'); + else if (state === 1) + return this.sonos.setPlayMode(shuffle ? 'SHUFFLE_REPEAT_ONE' : 'REPEAT_ONE'); + else if (state === 2) + return this.sonos.setPlayMode(shuffle ? 'SHUFFLE_NOREPEAT' : 'NORMAL'); + } + + async refresh() { + const {PlayMode: mode} = await this.sonos.getTransportSettings(); + if (mode === 'NORMAL' || mode === 'SHUFFLE_NOREPEAT') + return this.streamDeck.setState(0, this.context); + else if (mode === 'REPEAT_ALL' || mode === 'SHUFFLE') + return this.streamDeck.setState(1, this.context); + else if (mode === 'REPEAT_ONE' || mode === 'SHUFFLE_REPEAT_ONE') + return this.streamDeck.setState(2, this.context); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/shuffle.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/shuffle.js new file mode 100644 index 0000000..d572509 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/shuffle.js @@ -0,0 +1,16 @@ +define(class extends PollingAction { + async onKeyDown({payload: {state}}) { + const {PlayMode: mode} = await this.sonos.getTransportSettings(); + if (mode === 'NORMAL' || mode === 'SHUFFLE_NOREPEAT') + return this.sonos.setPlayMode(state === 0 ? 'SHUFFLE_NOREPEAT' : 'NORMAL'); + else if (mode === 'REPEAT_ALL' || mode === 'SHUFFLE') + return this.sonos.setPlayMode(state === 0 ? 'SHUFFLE' : 'REPEAT_ALL'); + else if (mode === 'REPEAT_ONE' || mode === 'SHUFFLE_REPEAT_ONE') + return this.sonos.setPlayMode(state === 0 ? 'SHUFFLE_REPEAT_ONE' : 'REPEAT_ONE'); + } + + async refresh() { + const {PlayMode: mode} = await this.sonos.getTransportSettings(); + return this.streamDeck.setState(mode.indexOf('SHUFFLE') === 0 ? 1 : 0, this.context); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/volume.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/volume.js new file mode 100644 index 0000000..69fd809 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/volume.js @@ -0,0 +1,5 @@ +define(class extends SonosAction { + async onKeyDown({payload: {settings}}) { + return this.sonos.setVolume(settings.volume || 50); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/volumedown.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/volumedown.js new file mode 100644 index 0000000..c9a48ac --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/volumedown.js @@ -0,0 +1,6 @@ +define(class extends SonosAction { + async onKeyDown({payload: {settings}}) { + const {CurrentVolume: volume} = await this.sonos.getVolume(); + return this.sonos.setVolume(parseInt(volume) - (parseInt(settings.volume) || 10)); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/actions/volumeup.js b/src/com.genericmale.sonos.sdPlugin/plugin/actions/volumeup.js new file mode 100644 index 0000000..b84cd19 --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/actions/volumeup.js @@ -0,0 +1,6 @@ +define(class extends SonosAction { + async onKeyDown({payload: {settings}}) { + const {CurrentVolume: volume} = await this.sonos.getVolume(); + return this.sonos.setVolume(parseInt(volume) + (parseInt(settings.volume) || 10)); + } +}); diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/index.html b/src/com.genericmale.sonos.sdPlugin/plugin/index.html new file mode 100644 index 0000000..4c4eb3b --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/index.html @@ -0,0 +1,20 @@ + + + + + Sonos + + + + + + + + + + + diff --git a/src/com.genericmale.sonos.sdPlugin/plugin/index.js b/src/com.genericmale.sonos.sdPlugin/plugin/index.js new file mode 100644 index 0000000..8867c5f --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/plugin/index.js @@ -0,0 +1,106 @@ +/** + * This is the first function StreamDeck Software calls, when + * establishing the connection to the plugin or the Property Inspector + * @param {string} port - The socket's port to communicate with StreamDeck software. + * @param {string} pluginUUID - A unique identifier, which StreamDeck uses to communicate with the plugin + * @param {string} registerEvent - Identifies, if the event is meant for the property inspector or the plugin. + * @param {string} info - Information about the host (StreamDeck) application + * @param {string} actionInfo - Context is an internal identifier used to communicate to the host application. + */ +function connectElgatoStreamDeckSocket(port, pluginUUID, registerEvent, info, actionInfo) { + const sonos = new Sonos(); + + const streamDeck = new StreamDeck(); + streamDeck.connect(port, pluginUUID, registerEvent, info, actionInfo); + streamDeck.onConnected(() => { + streamDeck.getGlobalSettings().then((settings) => { + sonos.connect(settings.host, parseInt(settings.port) || 1400); + }); + }) + + //load action implementations dynamically when the action becomes visible + const loadedScripts = {}; + streamDeck.on('willAppear', (event) => { + //use last part of uuid as filename + const action = event.action.split('.').pop(); + + const script = document.createElement('script'); + script.src = `./actions/${action}.js`; + + if (!loadedScripts[script.src]) { + loadedScripts[script.src] = {name: event.action, context: event.context}; + document.documentElement.firstChild.appendChild(script); + } + }); + + //action classes need to be registered using a call to define + window.define = (actionClass) => { + const scriptSrc = document.currentScript.src; + const action = loadedScripts[scriptSrc]; + action.instance = new actionClass(streamDeck, action.name, action.context); + action.instance.sonos = sonos; + } +} + +/** + * Base Action holding a sonos connection. + */ +class SonosAction extends Action { + sonos; +} + +/** + * Base Action to refresh a state with a basic poll interval stored in the refreshInterval action setting. + */ +class PollingAction extends SonosAction { + settings; + + constructor(streamDeck, action, context) { + super(streamDeck, action, context); + + this.visible = true; + this.streamDeck.getSettings(this.context); + } + + onDidReceiveSettings({payload: {settings}}) { + this.settings = settings; + + const interval = parseInt(settings.refreshInterval); + this.interval = isNaN(interval) ? 1 : interval; + + this.startPolling(); + } + + onWillAppear() { + this.visible = true; + this.startPolling(); + } + + onWillDisappear() { + this.visible = false; + clearTimeout(this.timer); + } + + startPolling() { + if (!this.visible) + return; + + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + + if (this.sonos.isConnected()) { + this.refresh().then(() => { + if (this.interval > 0) { + this.timer = setTimeout(() => this.startPolling(), this.interval * 1000); + } + }); + } else if (this.interval > 0) { + this.timer = setTimeout(() => this.startPolling(), this.interval * 1000); + } + } + + async refresh() { + } +} diff --git a/src/com.genericmale.sonos.sdPlugin/sonos.js b/src/com.genericmale.sonos.sdPlugin/sonos.js new file mode 100644 index 0000000..6e7fa3b --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/sonos.js @@ -0,0 +1,318 @@ +class Sonos { + static BROWSE_TYPE = { + ARTISTS: 'A:ARTIST', + ARTIST_ALBUMS: 'A:ALBUMARTIST', + ALBUMS: 'A:ALBUM', + GENRES: 'A:GENRE', + COMPOSERS: 'A:COMPOSER', + TRACKS: 'A:TRACKS', + PLAYLISTS: 'A:PLAYLISTS', + SHARES: 'S:', + SONOS_PLAYLISTS: 'SQ:', + CATEGORIES: 'A:', + SONOS_FAVORITES: 'FV:2', + RADIO_STATIONS: 'R:0/0', + RADIO_SHOWS: 'R:0/1' + } + + constructor() { + this.avTransport = new SonosService(this, 'AVTransport', 'MediaRenderer/AVTransport'); + this.renderingControl = new SonosService(this, 'RenderingControl', 'MediaRenderer/RenderingControl'); + this.zoneGroupTopology = new SonosService(this, 'ZoneGroupTopology'); + this.contentDirectory = new SonosService(this, 'ContentDirectory', 'MediaServer/ContentDirectory'); + } + + connect(host, port) { + this.host = host; + this.port = port; + } + + isConnected() { + return this.host && this.port; + } + + async getTransportInfo() { + return this.avTransport.execute('GetTransportInfo'); + } + + async play() { + return this.avTransport.execute('Play', {Speed: 1}); + } + + async pause() { + return this.avTransport.execute('Pause'); + } + + async next() { + return this.avTransport.execute('Next'); + } + + async previous() { + return this.avTransport.execute('Previous'); + } + + async getTransportSettings() { + return this.avTransport.execute('GetTransportSettings'); + } + + async setPlayMode(playMode) { + return this.avTransport.execute('SetPlayMode', {NewPlayMode: playMode}); + } + + async setLocalTransport(prefix, suffix) { + const zoneGroupState = await this.getZoneGroupState(); + const coordinator = zoneGroupState.querySelector('ZoneGroup').getAttribute('Coordinator'); + return this.setAVTransportURI(`${prefix}:${coordinator}${suffix || ''}`); + } + + async setAVTransportURI(uri, metadata) { + return this.avTransport.execute('SetAVTransportURI', {CurrentURI: uri, CurrentURIMetaData: metadata || ''}); + } + + async getPositionInfo() { + return this.avTransport.execute('GetPositionInfo'); + } + + async getZoneGroupState() { + //return from cache if we already fetched the zones + if (this.zoneGroupState) { + return Promise.resolve(this.zoneGroupState); + } + + const {ZoneGroupState: state} = await this.zoneGroupTopology.execute('GetZoneGroupState'); + const zoneGroupState = new DOMParser().parseFromString(state, 'text/xml'); + this.zoneGroupState = zoneGroupState; + return zoneGroupState; + } + + async getMute() { + return this.renderingControl.execute('GetMute', {Channel: 'Master'}); + } + + async setMute(mute) { + return this.renderingControl.execute('SetMute', {Channel: 'Master', DesiredMute: mute ? '1' : '0'}); + } + + async getVolume() { + return this.renderingControl.execute('GetVolume', {Channel: 'Master'}); + } + + async setVolume(volume) { + return this.renderingControl.execute('SetVolume', {Channel: 'Master', DesiredVolume: volume}); + } + + async setServiceURI(uri, metadata) { + if (uri.startsWith('x-sonosapi-stream:')) { + return this.setAVTransportURI(uri, metadata); + } + + //add playlist to end of queue + const {FirstTrackNumberEnqueued: trackNr} = await this.addURIToQueue(uri, metadata); + if (!trackNr) + throw new Error(`Failed to add URI "${uri}" to queue`); + + //switch source to queue + await this.setLocalTransport('x-rincon-queue', '#0'); + + //set active track to the first in the playlist + return this.seek('TRACK_NR', trackNr); + } + + async seek(unit, target) { + return this.avTransport.execute('Seek', {Unit: unit, Target: target}); + } + + async addURIToQueue(uri, metadata, position, next) { + return this.avTransport.execute('AddURIToQueue', { + EnqueuedURI: uri, + EnqueuedURIMetaData: metadata, + DesiredFirstTrackNumberEnqueued: position || 0, + EnqueueAsNext: next ? '1' : '0' + }); + } + + async browse(type, term, categories, start, count) { + let objectId = type; + if (categories) + objectId += '/' + categories.map(c => encodeURIComponent(c)).join('/') + if (term) + objectId += ':' + encodeURIComponent(type); + + const {Result: result} = await this.contentDirectory.execute('Browse', { + ObjectID: objectId, + BrowseFlag: 'BrowseDirectChildren', + Filter: '*', + StartingIndex: start || '0', + RequestedCount: count || '100', + SortCriteria: '' + }); + + const items = new DOMParser().parseFromString(result, 'text/xml'); + return [...items.querySelectorAll('item')].map(i => ({ + title: this.getElementText(i, 'dc:title'), + uri: this.getElementText(i, 'res'), + metadata: this.getElementText(i, 'r:resMD'), + albumArtURI: this.getAlbumArtURI(i) + })); + } + + getElementText(xml, elementName) { + const elements = xml.getElementsByTagName(elementName) + return elements.length && elements[0].childNodes.length ? + elements[0].childNodes[0].nodeValue : null; + } + + getAlbumArtURI(metadata) { + let albumArtURI = this.getElementText(metadata, 'upnp:albumArtURI'); + if (albumArtURI && !albumArtURI.startsWith('http')) + albumArtURI = `http://${this.host}:1400${albumArtURI}`; + return albumArtURI; + } +} + +class SonosService { + constructor(sonos, name, baseUrl) { + this.sonos = sonos; + this.name = name; + this.baseUrl = baseUrl || name; + } + + async execute(action, params) { + if (!this.sonos.isConnected()) + throw new Error('Not connected to sonos'); + + params = params || {}; + params.InstanceID = params.InstanceID || 0; + + const url = `http://${this.sonos.host}:${this.sonos.port}/${this.baseUrl}/Control`; + const soapAction = `"urn:schemas-upnp-org:service:${this.name}:1#${action}"`; + const xmlParams = Object.keys(params).map((key) => `<${key}>${this.escape(params[key])}`).join(''); + const request = ` + + ${xmlParams} + ` + + const data = await fetch(url, { + method: 'POST', + headers: { + SOAPAction: soapAction, + 'Content-type': 'text/xml; charset=utf8' + }, + body: request + }); + const responseText = await data.text(); + if (!data.ok) + throw new Error(responseText); + + const responseDocument = new DOMParser().parseFromString(responseText, 'text/xml'); + const response = {}; + responseDocument.querySelectorAll('Body>* *').forEach((node) => + response[node.nodeName] = node.textContent + ); + return response; + } + + escape(txt) { + return txt.toString() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +class MusicService { + static URI_TYPE = { + album: { + prefix: 'x-rincon-cpcontainer:1004206c', + key: '00040000', + class: 'object.container.album.musicAlbum' + }, + episode: { + prefix: '', + key: '00032020', + class: 'object.item.audioItem.musicTrack' + }, + track: { + prefix: '', + key: '00032020', + class: 'object.item.audioItem.musicTrack' + }, + show: { + prefix: 'x-rincon-cpcontainer:1006206c', + key: '1006206c', + class: 'object.container.playlistContainer' + }, + song: { + prefix: '', + key: '10032020', + class: 'object.item.audioItem.musicTrack' + }, + playlist: { + prefix: 'x-rincon-cpcontainer:1006206c', + key: '1006206c', + class: 'object.container.playlistContainer' + }, + radio: { + prefix: 'x-sonosapi-stream:', + key: 'F00092020', + class: 'object.item.audioItem.audioBroadcast' + } + } + + static FACTORIES = [ + (uri) => { + const m = uri.match(/spotify.*[:/](album|episode|playlist|show|track)[:/](\w+)/); + return m ? new MusicService(2311, m[1], `spotify:${m[1]}:${m[2]}`) : null; + }, + (uri) => { + const m = uri.match(/https:\/\/tidal.*[:/](album|track|playlist)[:/]([\w-]+)/); + return m ? new MusicService(44551, m[1], `${m[1]}/${m[2]}`) : null; + }, + (uri) => { + const m = uri.match(/https:\/\/www.deezer.*[:/](album|track|playlist)[:/]([\w-]+)/); + return m ? new MusicService(519, m[1], `${m[1]}-${m[2]}`) : null; + }, + (uri) => { + const m = uri.match(/https:\/\/music\.apple\.com\/\w+\/(album|playlist)\/[^/]+\/(?:pl\.)?([-a-zA-Z0-9]+)(?:\?i=(\d+))?/); + if (!m) return null; + + const type = m[3] ? 'song' : m[1]; + const id = m[3] || m[2]; + return new MusicService(52231, type, `${type}:${id}`); + }, + (uri) => { + const m = uri.match(/https:\/\/tunein.com\/(radio)\/.*(s\d+)/); + return m ? new MusicService(65031, m[1], m[2], 254) : null; + } + ] + + static parse(uri) { + for (const factory of MusicService.FACTORIES) { + const service = factory(uri); + if (service) return service; + } + } + + constructor(serviceId, type, uri, broadcastId) { + this.serviceId = serviceId; + this.type = MusicService.URI_TYPE[type]; + this.encodedUri = encodeURIComponent(uri); + this.broadcastId = broadcastId; + } + + get metadata() { + return ` + + Stream Deck${this.type.class} + SA_RINCON${this.serviceId}_ + + `; + } + + get uri() { + return this.type.prefix + this.encodedUri + (this.broadcastId ? `?sid=${this.broadcastId}` : ''); + } + +} diff --git a/src/com.genericmale.sonos.sdPlugin/stream-deck.js b/src/com.genericmale.sonos.sdPlugin/stream-deck.js new file mode 100644 index 0000000..c47632e --- /dev/null +++ b/src/com.genericmale.sonos.sdPlugin/stream-deck.js @@ -0,0 +1,529 @@ +/** + * @class StreamDeck + * StreamDeck object containing all required code to establish + * communication with SD-Software and the Property Inspector + */ +class StreamDeck { + port; + uuid; + messageType; + appInfo; + actionInfo; + + websocket; + eventList = new Map(); + + /** + * Connect to Stream Deck + * @param port + * @param pluginUUID + * @param registerEvent + * @param info + * @param actionInfo + */ + connect(port, pluginUUID, registerEvent, info, actionInfo) { + this.port = port; + this.uuid = pluginUUID; + this.messageType = registerEvent; + this.appInfo = JSON.parse(info); + this.actionInfo = actionInfo ? JSON.parse(actionInfo) : null; + + if (this.websocket) { + this.websocket.close(); + this.websocket = null; + } + + this.websocket = new WebSocket(`ws://127.0.0.1:${this.port}`); + + this.websocket.onopen = () => { + const json = { + event: this.messageType, + uuid: this.uuid, + }; + + this.websocket.send(JSON.stringify(json)); + + this.emit('connected', { + connection: this.websocket, + port: this.port, + uuid: this.uuid, + actionInfo: this.actionInfo, + appInfo: this.appInfo, + messageType: this.messageType, + }); + }; + + this.websocket.onerror = (evt) => { + const error = `WEBSOCKET ERROR: ${evt}, ${evt.data}, ${evt.code}`; + console.warn(error); + this.logMessage(error); + }; + + this.websocket.onclose = (evt) => { + console.warn('WEBSOCKET CLOSED:', evt); + }; + + this.websocket.onmessage = (evt) => { + const data = evt?.data ? JSON.parse(evt.data) : {}; + const {action, event} = data; + + if (event && event !== '') { + this.emit(event, data); + if (action && action !== '') { + this.emit(`${action}.${event}`, data); + } + } + }; + } + + /** + * Register for an event send by the Stream Deck application + */ + on(name, fn) { + if (!this.eventList.has(name)) { + const subscribers = new Set(); + + const sub = (fn) => { + subscribers.add(fn); + return () => subscribers.delete(fn); + }; + + const pub = (data) => subscribers.forEach((fn) => { + try { + Promise.resolve(fn(data)).catch((e) => { + this.showAlert(data.context); + console.error(e); + }); + } catch (e) { + this.showAlert(data.context); + console.error(e); + } + }); + const pubSub = Object.freeze({pub, sub}); + this.eventList.set(name, pubSub); + } + + return this.eventList.get(name).sub(fn); + } + + /** + * @private + */ + emit(name, data) { + return this.eventList.has(name) && this.eventList.get(name).pub(data); + } + + /** + * Send JSON payload to StreamDeck + * @private + */ + send(event, context, payload, additionalData) { + if (this.websocket) { + try { + const data = Object.assign({}, {event, context, payload}, additionalData); + this.websocket.send(JSON.stringify(data)); + } catch (e) { + console.log('WEBSOCKET EXCEPTION:', e); + } + } + } + + /** + * Save the actions's persistent data. + * @param settings + * @param context + */ + setSettings(settings, context) { + this.send('setSettings', context || this.uuid, settings); + } + + /** + * Request the actions's persistent data + * @param context + * @param timeout + */ + getSettings(context, timeout) { + return new Promise((resolve, reject) => { + const ctx = context || this.uuid; + this.send('getSettings', ctx); + const unsub = this.on('didReceiveSettings', (event) => { + if (event.context === ctx) { + resolve(event.payload.settings); + unsub(); + clearTimeout(handle); + } + }); + const handle = setTimeout(() => { + reject(); + unsub(); + }, timeout || 5000); + }); + } + + /** + * Save the plugin's persistent data + * @param globalSettings + */ + setGlobalSettings(globalSettings) { + this.send('setGlobalSettings', this.uuid, globalSettings); + } + + /** + * Request the plugin's persistent data + */ + getGlobalSettings(timeout) { + return new Promise((resolve, reject) => { + this.send('getGlobalSettings', this.uuid); + const unsub = this.on('didReceiveGlobalSettings', (event) => { + resolve(event.payload.settings); + unsub(); + clearTimeout(handle); + }); + const handle = setTimeout(() => { + reject(); + unsub(); + }, timeout || 5000); + }); + } + + /** + * Opens a URL in the default web browser + * @param url + */ + openUrl(url) { + this.send('openUrl', this.uuid, {url}); + } + + /** + * Write to log file + * @param message + */ + logMessage(message) { + this.send('logMessage', this.uuid, {message}); + } + + /** + * Set the title of the actions's key + * @param context + * @param title + * @param target + * @param state + */ + setTitle(title, target, state, context) { + this.send('setTitle', context || this.uuid, { + title, + target: target || 0, + state + }); + } + + /** + * Set the actions key image + * @param context + * @param image + * @param target + * @param state + */ + setImage(image, target, state, context) { + this.send('setImage', context || this.uuid, { + image, + target: target || 0, + state + }); + } + + /** + * Display alert triangle on actions key + * @param context + */ + showAlert(context) { + this.send('showAlert', context || this.uuid); + } + + /** + * Display ok check mark on actions key + * @param context + */ + showOk(context) { + this.send('showOk', context || this.uuid); + } + + /** + * Set the state of the actions + * @param context + * @param state + */ + setState(state, context) { + this.send('setState', context || this.uuid, {state}); + } + + /** + * Switch the active profile + * @param profile + * @param device + */ + switchToProfile(profile, device) { + this.send('switchToProfile', this.uuid, {profile}, {device}); + } + + /** + * Send payload to property inspector + * @param action + * @param payload + */ + sendToPropertyInspector(payload, action) { + this.send('sendToPropertyInspector', this.uuid, payload, { + action: action || this.actionInfo?.action + }); + } + + /** + * Send payload from the property inspector to the plugin + * @param payload + * @param action + */ + sendToPlugin(payload, action) { + this.send('sendToPlugin', this.uuid, payload, { + action: action || this.actionInfo?.action + }); + } + + /** + * Set an action image from a URL and optionally add some text to it + */ + setImageURL(imageURL, texts, titleParameters, context) { + const size = 72; + const padding = 5; //TODO: make padding configurable + + return new Promise((resolve, reject) => { + if(!imageURL) reject(); + + const image = new Image(); + image.onerror = reject; + image.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.height = size; + canvas.width = size; + + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + + if(texts) { + ctx.textBaseline = 'top'; + ctx.textAlign = 'center'; + ctx.shadowColor = "#000000"; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1; + + if(titleParameters) { + const fontStyle = titleParameters.fontStyle === 'Regular' ? '' : titleParameters.fontStyle; + ctx.font = `${fontStyle} ${titleParameters.fontSize}pt ${titleParameters.fontFamily || 'Arial'}`; + ctx.fillStyle = titleParameters.titleColor; + } + + if (typeof texts === 'string') + texts = {top: texts}; + if(texts.top) + ctx.fillText(texts.top, canvas.width / 2, padding); + if(texts.middle) + ctx.fillText(texts.middle, canvas.width / 2, (canvas.height - titleParameters.fontSize) / 2); + if(texts.bottom) + ctx.fillText(texts.bottom, canvas.width / 2, canvas.height - titleParameters.fontSize - padding); + } + + this.setImage(canvas.toDataURL('image/png'), 0, null, context); + resolve(); + }; + image.src = imageURL; + }); + } + + /** + * Registers a callback function for when Stream Deck is connected + */ + onConnected(fn) { + this.on('connected', (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the didReceiveGlobalSettings event, which fires when calling getGlobalSettings + */ + onDidReceiveGlobalSettings(fn) { + this.on('didReceiveGlobalSettings', (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the deviceDidConnect event, which fires when a device is plugged in + */ + onDeviceDidConnect(fn) { + this.on('deviceDidConnect', (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the deviceDidDisconnect event, which fires when a device is unplugged + */ + onDeviceDidDisconnect(fn) { + this.on('deviceDidDisconnect', (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the applicationDidLaunch event, which fires when the application starts + */ + onApplicationDidLaunch(fn) { + this.on('applicationDidLaunch', (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the applicationDidTerminate event, which fires when the application exits + */ + onApplicationDidTerminate(fn) { + this.on('applicationDidTerminate', (jsn) => fn(jsn)); + return this; + } + + /** + * Registers a callback function for the systemDidWakeUp event, which fires when the computer wakes + */ + onSystemDidWakeUp(fn) { + this.on('systemDidWakeUp', (jsn) => fn(jsn)); + return this; + } +} + +/** + * @class Action + * A Stream Deck plugin action, where you can register callback functions for different events + */ +class Action { + streamDeck; + action; + context; + + constructor(streamDeck, action, context) { + this.streamDeck = streamDeck; + this.action = action + this.context = context; + + //action events + this.streamDeck.on(`${this.action}.didReceiveSettings`, (event) => this.onDidReceiveSettings(event)); + this.streamDeck.on(`${this.action}.keyDown`, (event) => this.onKeyDown(event)); + this.streamDeck.on(`${this.action}.keyUp`, (event) => this.onKeyUp(event)); + this.streamDeck.on(`${this.action}.willAppear`, (event) => this.onWillAppear(event)); + this.streamDeck.on(`${this.action}.willDisappear`, (event) => this.onWillDisappear(event)); + this.streamDeck.on(`${this.action}.titleParametersDidChange`, (event) => this.onTitleParametersDidChange(event)); + this.streamDeck.on(`${this.action}.propertyInspectorDidAppear`, (event) => this.onPropertyInspectorDidAppear(event)); + this.streamDeck.on(`${this.action}.propertyInspectorDidDisappear`, (event) => this.onPropertyInspectorDidDisappear(event)); + this.streamDeck.on(`${this.action}.sendToPlugin`, (event) => this.onSendToPlugin(event)); + this.streamDeck.on(`${this.action}.sendToPropertyInspector`, (event) => this.onSendToPropertyInspector(event)); + + //global events + this.streamDeck + .onDidReceiveGlobalSettings((event) => this.onDidReceiveGlobalSettings(event)) + .onDeviceDidConnect((event) => this.onDeviceDidConnect(event)) + .onDeviceDidDisconnect((event) => this.onDeviceDidDisconnect(event)) + .onApplicationDidLaunch((event) => this.onApplicationDidLaunch(event)) + .onApplicationDidTerminate((event) => this.onApplicationDidTerminate(event)) + .onSystemDidWakeUp((event) => this.onSystemDidWakeUp(event)); + } + + /** + * Callback function for the didReceiveSettings event, which fires when calling getSettings + */ + async onDidReceiveSettings(event) { + } + + /** + * Callback function for the didReceiveGlobalSettings event, which fires when calling getGlobalSettings + */ + async onDidReceiveGlobalSettings(event) { + } + + /** + * Callback function for the keyDown event, which fires when pressing a key down + */ + async onKeyDown(event) { + } + + /** + * Callback function for the keyUp event, which fires when releasing a key + */ + async onKeyUp(event) { + } + + /** + * Callback function for the willAppear event, which fires when an action appears on they key + */ + async onWillAppear(event) { + } + + /** + * Callback function for the willAppear event, which fires when an action appears on they key + */ + async onWillDisappear(event) { + } + + /** + * Callback function for the titleParametersDidChange event, which fires when a user changes the key title + */ + async onTitleParametersDidChange(event) { + } + + /** + * Callback function for the deviceDidConnect event, which fires when a device is plugged in + */ + async onDeviceDidConnect(event) { + } + + /** + * Callback function for the deviceDidDisconnect event, which fires when a device is unplugged + */ + async onDeviceDidDisconnect(event) { + } + + /** + * Callback function for the applicationDidLaunch event, which fires when the application starts + */ + async onApplicationDidLaunch(event) { + } + + /** + * Callback function for the applicationDidTerminate event, which fires when the application exits + */ + async onApplicationDidTerminate(event) { + } + + /** + * Callback function for the systemDidWakeUp event, which fires when the computer wakes + */ + async onSystemDidWakeUp(event) { + } + + /** + * Callback function for the propertyInspectorDidAppear event, which fires when the property inspector is displayed + */ + async onPropertyInspectorDidAppear(event) { + } + + /** + * Callback function for the propertyInspectorDidDisappear event, which fires when the property inspector is closed + */ + async onPropertyInspectorDidDisappear(event) { + } + + /** + * Callback function for the sendToPlugin event, which fires when the property inspector uses the sendToPlugin api + */ + async onSendToPlugin(event) { + } + + /** + * Callback function for the sendToPropertyInspector event, which fires when the plugin uses the sendToPropertyInspector api + */ + async onSendToPropertyInspector(event) { + } +}