Ver Fonte

feat: thumbnail overlay

Sv443 há 1 ano atrás
pai
commit
079ba245b2

+ 1 - 0
assets/icons/image.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path fill="#ffffff" d="M224.615-160Q197-160 178.5-178.5 160-197 160-224.615v-510.77Q160-763 178.5-781.5 197-800 224.615-800h510.77Q763-800 781.5-781.5 800-763 800-735.385v510.77Q800-197 781.5-178.5 763-160 735.385-160h-510.77Zm0-40h510.77q9.23 0 16.923-7.692Q760-215.385 760-224.615v-510.77q0-9.23-7.692-16.923Q744.615-760 735.385-760h-510.77q-9.23 0-16.923 7.692Q200-744.615 200-735.385v510.77q0 9.23 7.692 16.923Q215.385-200 224.615-200ZM300-300h366.154L553.077-450.769 448.462-318.462l-70.001-84.615L300-300ZM200-200v-560 560Z"/></svg>

+ 1 - 0
assets/icons/image_off.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path fill="#ffffff" d="m800-274-40-40v-421.385q0-9.23-7.692-16.923Q744.615-760 735.385-760H314l-40-40h461.385Q763-800 781.5-781.5 800-763 800-735.385V-274Zm19.692 190.308L743.385-160h-518.77Q197-160 178.5-178.5 160-197 160-224.615v-518.77l-76.308-76.307L112-848l736 736-28.308 28.308ZM300-300l78.461-103.077 70.001 84.615L509.154-394 200-703.154v478.539q0 9.23 7.692 16.923Q215.385-200 224.615-200h478.539l-100-100H300Zm237-237Zm-85.308 85.308Z"/></svg>

+ 2 - 0
assets/resources.json

@@ -9,6 +9,8 @@
   "icon-experimental": "icons/beaker_small.svg",
   "icon-globe": "icons/globe.svg",
   "icon-help": "icons/help.svg",
+  "icon-image": "icons/image.svg",
+  "icon-image_off": "icons/image_off.svg",
   "icon-lock": "icons/lock.svg",
   "icon-lock_off": "icons/lock_off.svg",
   "icon-link": "icons/link.svg",

+ 86 - 16
assets/translations/README.md

@@ -20,15 +20,15 @@ To submit or edit a translation, please follow [this guide](../../contributing.m
 ### Translation progress:
 | &nbsp; | Locale | Translated keys | Based on |
 | :----: | ------ | --------------- | :------: |
-| ─ | [`en_US`](./en_US.json) | 181 (default locale) |  |
-| ‼️ | [`de_DE`](./de_DE.json) | `161/181` (89%) | ─ |
-| ─ | [`en_UK`](./en_UK.json) | `181/181` (100%) | `en_US` |
-| ‼️ | [`es_ES`](./es_ES.json) | `161/181` (89%) | ─ |
-| ‼️ | [`fr_FR`](./fr_FR.json) | `161/181` (89%) | ─ |
-| ‼️ | [`hi_IN`](./hi_IN.json) | `161/181` (89%) | ─ |
-| ‼️ | [`ja_JA`](./ja_JA.json) | `161/181` (89%) | ─ |
-| ‼️ | [`pt_BR`](./pt_BR.json) | `161/181` (89%) | ─ |
-| ‼️ | [`zh_CN`](./zh_CN.json) | `161/181` (89%) | ─ |
+| ─ | [`en_US`](./en_US.json) | 191 (default locale) |  |
+| ‼️ | [`de_DE`](./de_DE.json) | `161/191` (84.3%) | ─ |
+| ─ | [`en_UK`](./en_UK.json) | `191/191` (100%) | `en_US` |
+| ‼️ | [`es_ES`](./es_ES.json) | `161/191` (84.3%) | ─ |
+| ‼️ | [`fr_FR`](./fr_FR.json) | `161/191` (84.3%) | ─ |
+| ‼️ | [`hi_IN`](./hi_IN.json) | `161/191` (84.3%) | ─ |
+| ‼️ | [`ja_JA`](./ja_JA.json) | `161/191` (84.3%) | ─ |
+| ‼️ | [`pt_BR`](./pt_BR.json) | `161/191` (84.3%) | ─ |
+| ‼️ | [`zh_CN`](./zh_CN.json) | `161/191` (84.3%) | ─ |
 
 <sub>
 ✅ - Fully translated
@@ -49,7 +49,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 ### Missing keys:
 
-<details><summary><code>de_DE</code> - 20 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>de_DE</code> - 30 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -62,12 +62,22 @@ This means to figure out which keys are untranslated, you will need to manually
 | `dev_mode` | `Developer mode` |
 | `dev_mode_short` | `Dev` |
 | `advanced_mode_short` | `Advanced` |
+| `thumbnail_overlay_behavior_never` | `Never` |
+| `thumbnail_overlay_behavior_videos_only` | `Only for videos` |
+| `thumbnail_overlay_behavior_songs_only` | `Only for songs` |
+| `thumbnail_overlay_behavior_always` | `Always` |
+| `thumbnail_overlay_toggle_btn_tooltip_hide` | `Click to hide the thumbnail overlay` |
+| `thumbnail_overlay_toggle_btn_tooltip_show` | `Click to show the thumbnail overlay` |
 | `feature_category_volume` | `Volume` |
 | `feature_desc_volumeSharedBetweenTabs` | `Should the set volume be shared between tabs and remembered between sessions?` |
 | `feature_helptext_volumeSharedBetweenTabs` | `If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.` |
 | `feature_desc_setInitialTabVolume` | `Sets the volume level to a specific value once when opening the site` |
 | `feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible` | `This feature is incompatible with the "Volume level shared between tabs" feature and will be ignored while using the shared volume feature!` |
 | `feature_desc_initialTabVolumeLevel` | `The value to set the volume level to when opening the site` |
+| `feature_desc_thumbnailOverlayBehavior` | `When to show the thumbnail as an overlay over the video player` |
+| `feature_helptext_thumbnailOverlayBehavior` | `The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!` |
+| `feature_desc_thumbnailOverlayToggleBtnShown` | `Add a button to the media controls to manually toggle the thumbnail overlay` |
+| `feature_helptext_thumbnailOverlayToggleBtnShown` | `This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.` |
 | `feature_desc_rememberSongTimeDuration` | `How long in seconds to remember the song's time for after it was last played` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
@@ -76,7 +86,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 <br></details>
 
-<details><summary><code>es_ES</code> - 20 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>es_ES</code> - 30 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -89,12 +99,22 @@ This means to figure out which keys are untranslated, you will need to manually
 | `dev_mode` | `Developer mode` |
 | `dev_mode_short` | `Dev` |
 | `advanced_mode_short` | `Advanced` |
+| `thumbnail_overlay_behavior_never` | `Never` |
+| `thumbnail_overlay_behavior_videos_only` | `Only for videos` |
+| `thumbnail_overlay_behavior_songs_only` | `Only for songs` |
+| `thumbnail_overlay_behavior_always` | `Always` |
+| `thumbnail_overlay_toggle_btn_tooltip_hide` | `Click to hide the thumbnail overlay` |
+| `thumbnail_overlay_toggle_btn_tooltip_show` | `Click to show the thumbnail overlay` |
 | `feature_category_volume` | `Volume` |
 | `feature_desc_volumeSharedBetweenTabs` | `Should the set volume be shared between tabs and remembered between sessions?` |
 | `feature_helptext_volumeSharedBetweenTabs` | `If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.` |
 | `feature_desc_setInitialTabVolume` | `Sets the volume level to a specific value once when opening the site` |
 | `feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible` | `This feature is incompatible with the "Volume level shared between tabs" feature and will be ignored while using the shared volume feature!` |
 | `feature_desc_initialTabVolumeLevel` | `The value to set the volume level to when opening the site` |
+| `feature_desc_thumbnailOverlayBehavior` | `When to show the thumbnail as an overlay over the video player` |
+| `feature_helptext_thumbnailOverlayBehavior` | `The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!` |
+| `feature_desc_thumbnailOverlayToggleBtnShown` | `Add a button to the media controls to manually toggle the thumbnail overlay` |
+| `feature_helptext_thumbnailOverlayToggleBtnShown` | `This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.` |
 | `feature_desc_rememberSongTimeDuration` | `How long in seconds to remember the song's time for after it was last played` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
@@ -103,7 +123,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 <br></details>
 
-<details><summary><code>fr_FR</code> - 20 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>fr_FR</code> - 30 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -116,12 +136,22 @@ This means to figure out which keys are untranslated, you will need to manually
 | `dev_mode` | `Developer mode` |
 | `dev_mode_short` | `Dev` |
 | `advanced_mode_short` | `Advanced` |
+| `thumbnail_overlay_behavior_never` | `Never` |
+| `thumbnail_overlay_behavior_videos_only` | `Only for videos` |
+| `thumbnail_overlay_behavior_songs_only` | `Only for songs` |
+| `thumbnail_overlay_behavior_always` | `Always` |
+| `thumbnail_overlay_toggle_btn_tooltip_hide` | `Click to hide the thumbnail overlay` |
+| `thumbnail_overlay_toggle_btn_tooltip_show` | `Click to show the thumbnail overlay` |
 | `feature_category_volume` | `Volume` |
 | `feature_desc_volumeSharedBetweenTabs` | `Should the set volume be shared between tabs and remembered between sessions?` |
 | `feature_helptext_volumeSharedBetweenTabs` | `If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.` |
 | `feature_desc_setInitialTabVolume` | `Sets the volume level to a specific value once when opening the site` |
 | `feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible` | `This feature is incompatible with the "Volume level shared between tabs" feature and will be ignored while using the shared volume feature!` |
 | `feature_desc_initialTabVolumeLevel` | `The value to set the volume level to when opening the site` |
+| `feature_desc_thumbnailOverlayBehavior` | `When to show the thumbnail as an overlay over the video player` |
+| `feature_helptext_thumbnailOverlayBehavior` | `The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!` |
+| `feature_desc_thumbnailOverlayToggleBtnShown` | `Add a button to the media controls to manually toggle the thumbnail overlay` |
+| `feature_helptext_thumbnailOverlayToggleBtnShown` | `This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.` |
 | `feature_desc_rememberSongTimeDuration` | `How long in seconds to remember the song's time for after it was last played` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
@@ -130,7 +160,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 <br></details>
 
-<details><summary><code>hi_IN</code> - 20 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>hi_IN</code> - 30 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -143,12 +173,22 @@ This means to figure out which keys are untranslated, you will need to manually
 | `dev_mode` | `Developer mode` |
 | `dev_mode_short` | `Dev` |
 | `advanced_mode_short` | `Advanced` |
+| `thumbnail_overlay_behavior_never` | `Never` |
+| `thumbnail_overlay_behavior_videos_only` | `Only for videos` |
+| `thumbnail_overlay_behavior_songs_only` | `Only for songs` |
+| `thumbnail_overlay_behavior_always` | `Always` |
+| `thumbnail_overlay_toggle_btn_tooltip_hide` | `Click to hide the thumbnail overlay` |
+| `thumbnail_overlay_toggle_btn_tooltip_show` | `Click to show the thumbnail overlay` |
 | `feature_category_volume` | `Volume` |
 | `feature_desc_volumeSharedBetweenTabs` | `Should the set volume be shared between tabs and remembered between sessions?` |
 | `feature_helptext_volumeSharedBetweenTabs` | `If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.` |
 | `feature_desc_setInitialTabVolume` | `Sets the volume level to a specific value once when opening the site` |
 | `feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible` | `This feature is incompatible with the "Volume level shared between tabs" feature and will be ignored while using the shared volume feature!` |
 | `feature_desc_initialTabVolumeLevel` | `The value to set the volume level to when opening the site` |
+| `feature_desc_thumbnailOverlayBehavior` | `When to show the thumbnail as an overlay over the video player` |
+| `feature_helptext_thumbnailOverlayBehavior` | `The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!` |
+| `feature_desc_thumbnailOverlayToggleBtnShown` | `Add a button to the media controls to manually toggle the thumbnail overlay` |
+| `feature_helptext_thumbnailOverlayToggleBtnShown` | `This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.` |
 | `feature_desc_rememberSongTimeDuration` | `How long in seconds to remember the song's time for after it was last played` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
@@ -157,7 +197,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 <br></details>
 
-<details><summary><code>ja_JA</code> - 20 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>ja_JA</code> - 30 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -170,12 +210,22 @@ This means to figure out which keys are untranslated, you will need to manually
 | `dev_mode` | `Developer mode` |
 | `dev_mode_short` | `Dev` |
 | `advanced_mode_short` | `Advanced` |
+| `thumbnail_overlay_behavior_never` | `Never` |
+| `thumbnail_overlay_behavior_videos_only` | `Only for videos` |
+| `thumbnail_overlay_behavior_songs_only` | `Only for songs` |
+| `thumbnail_overlay_behavior_always` | `Always` |
+| `thumbnail_overlay_toggle_btn_tooltip_hide` | `Click to hide the thumbnail overlay` |
+| `thumbnail_overlay_toggle_btn_tooltip_show` | `Click to show the thumbnail overlay` |
 | `feature_category_volume` | `Volume` |
 | `feature_desc_volumeSharedBetweenTabs` | `Should the set volume be shared between tabs and remembered between sessions?` |
 | `feature_helptext_volumeSharedBetweenTabs` | `If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.` |
 | `feature_desc_setInitialTabVolume` | `Sets the volume level to a specific value once when opening the site` |
 | `feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible` | `This feature is incompatible with the "Volume level shared between tabs" feature and will be ignored while using the shared volume feature!` |
 | `feature_desc_initialTabVolumeLevel` | `The value to set the volume level to when opening the site` |
+| `feature_desc_thumbnailOverlayBehavior` | `When to show the thumbnail as an overlay over the video player` |
+| `feature_helptext_thumbnailOverlayBehavior` | `The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!` |
+| `feature_desc_thumbnailOverlayToggleBtnShown` | `Add a button to the media controls to manually toggle the thumbnail overlay` |
+| `feature_helptext_thumbnailOverlayToggleBtnShown` | `This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.` |
 | `feature_desc_rememberSongTimeDuration` | `How long in seconds to remember the song's time for after it was last played` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
@@ -184,7 +234,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 <br></details>
 
-<details><summary><code>pt_BR</code> - 20 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>pt_BR</code> - 30 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -197,12 +247,22 @@ This means to figure out which keys are untranslated, you will need to manually
 | `dev_mode` | `Developer mode` |
 | `dev_mode_short` | `Dev` |
 | `advanced_mode_short` | `Advanced` |
+| `thumbnail_overlay_behavior_never` | `Never` |
+| `thumbnail_overlay_behavior_videos_only` | `Only for videos` |
+| `thumbnail_overlay_behavior_songs_only` | `Only for songs` |
+| `thumbnail_overlay_behavior_always` | `Always` |
+| `thumbnail_overlay_toggle_btn_tooltip_hide` | `Click to hide the thumbnail overlay` |
+| `thumbnail_overlay_toggle_btn_tooltip_show` | `Click to show the thumbnail overlay` |
 | `feature_category_volume` | `Volume` |
 | `feature_desc_volumeSharedBetweenTabs` | `Should the set volume be shared between tabs and remembered between sessions?` |
 | `feature_helptext_volumeSharedBetweenTabs` | `If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.` |
 | `feature_desc_setInitialTabVolume` | `Sets the volume level to a specific value once when opening the site` |
 | `feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible` | `This feature is incompatible with the "Volume level shared between tabs" feature and will be ignored while using the shared volume feature!` |
 | `feature_desc_initialTabVolumeLevel` | `The value to set the volume level to when opening the site` |
+| `feature_desc_thumbnailOverlayBehavior` | `When to show the thumbnail as an overlay over the video player` |
+| `feature_helptext_thumbnailOverlayBehavior` | `The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!` |
+| `feature_desc_thumbnailOverlayToggleBtnShown` | `Add a button to the media controls to manually toggle the thumbnail overlay` |
+| `feature_helptext_thumbnailOverlayToggleBtnShown` | `This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.` |
 | `feature_desc_rememberSongTimeDuration` | `How long in seconds to remember the song's time for after it was last played` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
@@ -211,7 +271,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 <br></details>
 
-<details><summary><code>zh_CN</code> - 20 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>zh_CN</code> - 30 missing keys <i>(click to show)</i></summary><br>
 
 | Key | English text |
 | --- | ------------ |
@@ -224,12 +284,22 @@ This means to figure out which keys are untranslated, you will need to manually
 | `dev_mode` | `Developer mode` |
 | `dev_mode_short` | `Dev` |
 | `advanced_mode_short` | `Advanced` |
+| `thumbnail_overlay_behavior_never` | `Never` |
+| `thumbnail_overlay_behavior_videos_only` | `Only for videos` |
+| `thumbnail_overlay_behavior_songs_only` | `Only for songs` |
+| `thumbnail_overlay_behavior_always` | `Always` |
+| `thumbnail_overlay_toggle_btn_tooltip_hide` | `Click to hide the thumbnail overlay` |
+| `thumbnail_overlay_toggle_btn_tooltip_show` | `Click to show the thumbnail overlay` |
 | `feature_category_volume` | `Volume` |
 | `feature_desc_volumeSharedBetweenTabs` | `Should the set volume be shared between tabs and remembered between sessions?` |
 | `feature_helptext_volumeSharedBetweenTabs` | `If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.` |
 | `feature_desc_setInitialTabVolume` | `Sets the volume level to a specific value once when opening the site` |
 | `feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible` | `This feature is incompatible with the "Volume level shared between tabs" feature and will be ignored while using the shared volume feature!` |
 | `feature_desc_initialTabVolumeLevel` | `The value to set the volume level to when opening the site` |
+| `feature_desc_thumbnailOverlayBehavior` | `When to show the thumbnail as an overlay over the video player` |
+| `feature_helptext_thumbnailOverlayBehavior` | `The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!` |
+| `feature_desc_thumbnailOverlayToggleBtnShown` | `Add a button to the media controls to manually toggle the thumbnail overlay` |
+| `feature_helptext_thumbnailOverlayToggleBtnShown` | `This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.` |
 | `feature_desc_rememberSongTimeDuration` | `How long in seconds to remember the song's time for after it was last played` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |

+ 11 - 0
assets/translations/en_US.json

@@ -111,6 +111,13 @@
     "collapse_release_notes": "Click to collapse the latest release notes",
     "no_updates_found": "No updates found.",
 
+    "thumbnail_overlay_behavior_never": "Never",
+    "thumbnail_overlay_behavior_videos_only": "Only for videos",
+    "thumbnail_overlay_behavior_songs_only": "Only for songs",
+    "thumbnail_overlay_behavior_always": "Always",
+    "thumbnail_overlay_toggle_btn_tooltip_hide": "Click to hide the thumbnail overlay",
+    "thumbnail_overlay_toggle_btn_tooltip_show": "Click to show the thumbnail overlay",
+
     "unit_entries-1": "entry",
     "unit_entries-n": "entries",
     "unit_days-1": "day",
@@ -143,6 +150,10 @@
     "feature_desc_setInitialTabVolume": "Sets the volume level to a specific value once when opening the site",
     "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "This feature is incompatible with the \"Volume level shared between tabs\" feature and will be ignored while using the shared volume feature!",
     "feature_desc_initialTabVolumeLevel": "The value to set the volume level to when opening the site",
+    "feature_desc_thumbnailOverlayBehavior": "When to show the thumbnail as an overlay over the video player",
+    "feature_helptext_thumbnailOverlayBehavior": "The thumbnail overlay will be shown over top of the currently playing video or song.\nThis will not save any bandwidth as the video will still be loaded and played in the background!",
+    "feature_desc_thumbnailOverlayToggleBtnShown": "Add a button to the media controls to manually toggle the thumbnail overlay",
+    "feature_helptext_thumbnailOverlayToggleBtnShown": "This button will allow you to manually toggle the thumbnail overlay on and off.\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.",
 
     "feature_desc_lyricsQueueButton": "Add a button to each song in a queue to quickly open its lyrics page",
     "feature_desc_deleteFromQueueButton": "Add a button to each song in a queue to quickly remove it",

+ 285 - 66
dist/BetterYTM.user.js

@@ -17,7 +17,7 @@
 // @license           AGPL-3.0-only
 // @author            Sv443
 // @copyright         Sv443 (https://github.com/Sv443)
-// @icon              https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/images/logo/logo_48.png?b=a0d1b60
+// @icon              http://localhost:8710/assets/images/logo/logo_48.png?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
 // @match             https://music.youtube.com/*
 // @match             https://www.youtube.com/*
 // @run-at            document-start
@@ -32,40 +32,43 @@
 // @grant             GM.getResourceUrl
 // @grant             GM.setClipboard
 // @grant             GM.xmlHttpRequest
+// @grant             GM.openInTab
 // @grant             unsafeWindow
 // @noframes
-// @resource          css-anchor_improvements https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/style/anchorImprovements.css?b=a0d1b60
-// @resource          css-fix_spacing         https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/style/fixSpacing.css?b=a0d1b60
-// @resource          doc-changelog           https://raw.githubusercontent.com/Sv443/BetterYTM/develop/changelog.md?b=a0d1b60
-// @resource          icon-advanced_mode      https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/plus_circle_small.svg?b=a0d1b60
-// @resource          icon-arrow_down         https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/arrow_down.svg?b=a0d1b60
-// @resource          icon-delete             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/delete.svg?b=a0d1b60
-// @resource          icon-error              https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/error.svg?b=a0d1b60
-// @resource          icon-experimental       https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/beaker_small.svg?b=a0d1b60
-// @resource          icon-globe              https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/globe.svg?b=a0d1b60
-// @resource          icon-help               https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/help.svg?b=a0d1b60
-// @resource          icon-lock               https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/lock.svg?b=a0d1b60
-// @resource          icon-lock_off           https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/lock_off.svg?b=a0d1b60
-// @resource          icon-link               https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/link.svg?b=a0d1b60
-// @resource          icon-link_off           https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/link_off.svg?b=a0d1b60
-// @resource          icon-lyrics             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/lyrics.svg?b=a0d1b60
-// @resource          icon-skip_to            https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/skip_to.svg?b=a0d1b60
-// @resource          icon-spinner            https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/icons/spinner.svg?b=a0d1b60
-// @resource          img-logo                https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/images/logo/logo_48.png?b=a0d1b60
-// @resource          img-close               https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/images/close.png?b=a0d1b60
-// @resource          img-discord             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/images/external/discord.png?b=a0d1b60
-// @resource          img-github              https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/images/external/github.png?b=a0d1b60
-// @resource          img-greasyfork          https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/images/external/greasyfork.png?b=a0d1b60
-// @resource          img-openuserjs          https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/images/external/openuserjs.png?b=a0d1b60
-// @resource          trans-de_DE             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/de_DE.json?b=a0d1b60
-// @resource          trans-en_US             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/en_US.json?b=a0d1b60
-// @resource          trans-en_UK             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/en_UK.json?b=a0d1b60
-// @resource          trans-es_ES             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/es_ES.json?b=a0d1b60
-// @resource          trans-fr_FR             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/fr_FR.json?b=a0d1b60
-// @resource          trans-hi_IN             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/hi_IN.json?b=a0d1b60
-// @resource          trans-ja_JA             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/ja_JA.json?b=a0d1b60
-// @resource          trans-pt_BR             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/pt_BR.json?b=a0d1b60
-// @resource          trans-zh_CN             https://raw.githubusercontent.com/Sv443/BetterYTM/develop/assets/translations/zh_CN.json?b=a0d1b60
+// @resource          css-anchor_improvements http://localhost:8710/assets/style/anchorImprovements.css?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          css-fix_spacing         http://localhost:8710/assets/style/fixSpacing.css?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          doc-changelog           http://localhost:8710/changelog.md?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-advanced_mode      http://localhost:8710/assets/icons/plus_circle_small.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-arrow_down         http://localhost:8710/assets/icons/arrow_down.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-delete             http://localhost:8710/assets/icons/delete.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-error              http://localhost:8710/assets/icons/error.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-experimental       http://localhost:8710/assets/icons/beaker_small.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-globe              http://localhost:8710/assets/icons/globe.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-help               http://localhost:8710/assets/icons/help.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-image              http://localhost:8710/assets/icons/image.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-image_off          http://localhost:8710/assets/icons/image_off.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-lock               http://localhost:8710/assets/icons/lock.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-lock_off           http://localhost:8710/assets/icons/lock_off.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-link               http://localhost:8710/assets/icons/link.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-link_off           http://localhost:8710/assets/icons/link_off.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-lyrics             http://localhost:8710/assets/icons/lyrics.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-skip_to            http://localhost:8710/assets/icons/skip_to.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          icon-spinner            http://localhost:8710/assets/icons/spinner.svg?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          img-logo                http://localhost:8710/assets/images/logo/logo_48.png?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          img-close               http://localhost:8710/assets/images/close.png?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          img-discord             http://localhost:8710/assets/images/external/discord.png?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          img-github              http://localhost:8710/assets/images/external/github.png?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          img-greasyfork          http://localhost:8710/assets/images/external/greasyfork.png?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          img-openuserjs          http://localhost:8710/assets/images/external/openuserjs.png?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-de_DE             http://localhost:8710/assets/translations/de_DE.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-en_US             http://localhost:8710/assets/translations/en_US.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-en_UK             http://localhost:8710/assets/translations/en_UK.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-es_ES             http://localhost:8710/assets/translations/es_ES.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-fr_FR             http://localhost:8710/assets/translations/fr_FR.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-hi_IN             http://localhost:8710/assets/translations/hi_IN.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-ja_JA             http://localhost:8710/assets/translations/ja_JA.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-pt_BR             http://localhost:8710/assets/translations/pt_BR.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
+// @resource          trans-zh_CN             http://localhost:8710/assets/translations/zh_CN.json?b=a19b6c80-4220-4071-8142-a6f05ebbc8b9
 // @require           https://cdn.jsdelivr.net/npm/@sv443-network/[email protected]/dist/index.global.js
 // @require           https://cdn.jsdelivr.net/npm/[email protected]/dist/fuse.basic.js
 // @require           https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.js
@@ -256,7 +259,8 @@ function clearNode(element) {
     element.parentNode.removeChild(element);
 }
 /**
- * Adds generic, accessible interaction listeners to the passed element
+ * Adds generic, accessible interaction listeners to the passed element.
+ * All listeners have the default behavior prevented and stop immediate propagation.
  * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners
  */
 function onInteraction(elem, listener, listenerOptions) {
@@ -297,7 +301,7 @@ var PluginIntent;
 })(PluginIntent || (PluginIntent = {}));const modeRaw = "development";
 const branchRaw = "develop";
 const hostRaw = "github";
-const buildNumberRaw = "a0d1b60";
+const buildNumberRaw = "ad6724a";
 /** The mode in which the script was built (production or development) */
 const mode = (modeRaw.match(/^#{{.+}}$/) ? "production" : modeRaw);
 /** The branch to use in various URLs that point to the GitHub repo */
@@ -336,33 +340,34 @@ const scriptInfo = {
 const defaultObserverOptions = {
     defaultDebounce: 100,
 };
-const observers$1 = {};
+/** Global SelectorObserver instances usable throughout the script for improved performance */
+const globservers = {};
 /** Call after DOM load to initialize all SelectorObserver instances */
 function initObservers() {
     try {
         // #SECTION body = the entire <body> element - use sparingly due to performance impacts!
-        observers$1.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false }));
-        observers$1.body.enable();
+        globservers.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false }));
+        globservers.body.enable();
         // #SECTION playerBar = media controls bar at the bottom of the page
         const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app";
-        observers$1.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 }));
-        observers$1.body.addListener(playerBarSelector, {
+        globservers.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 }));
+        globservers.body.addListener(playerBarSelector, {
             listener: () => {
                 log("#DBG-UU enabling playerBar observer");
-                observers$1.playerBar.enable();
+                globservers.playerBar.enable();
             },
         });
         // #SECTION playerBarInfo = song title, artist, album, etc. inside the player bar
         const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`;
-        observers$1.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] }));
-        observers$1.playerBarInfo.addListener(playerBarInfoSelector, {
+        globservers.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] }));
+        globservers.playerBarInfo.addListener(playerBarInfoSelector, {
             listener: () => {
                 log("#DBG-UU enabling playerBarTitle observer");
-                observers$1.playerBarInfo.enable();
+                globservers.playerBarInfo.enable();
             },
         });
         // #DEBUG example: listen for title change:
-        observers$1.playerBarInfo.addListener("yt-formatted-string.title", {
+        globservers.playerBarInfo.addListener("yt-formatted-string.title", {
             continuous: true,
             listener: (titleElem) => {
                 log("#DBG-UU >>>>> title changed", titleElem.title);
@@ -376,7 +381,7 @@ function initObservers() {
 }
 /** Interface function for adding listeners to the already present observers */
 function addSelectorListener(observerName, selector, options) {
-    observers$1[observerName].addListener(selector, options);
+    globservers[observerName].addListener(selector, options);
 }var de_DE = {
 	name: "Deutsch (Deutschland)",
 	nameEnglish: "German",
@@ -985,6 +990,8 @@ const allSiteEvents = [
     "hotkeyInputActive",
     "queueChanged",
     "autoplayQueueChanged",
+    "songTitleChanged",
+    "watchIdChanged",
 ];
 /** EventEmitter instance that is used to detect changes to the site */
 const siteEvents = createNanoEvents();
@@ -1002,8 +1009,12 @@ function initSiteEvents() {
                 }
             });
             // only observe added or removed elements
-            queueObs.observe(document.querySelector("#side-panel #contents.ytmusic-player-queue"), {
-                childList: true,
+            onSelectorOld("#side-panel #contents.ytmusic-player-queue", {
+                listener: (el) => {
+                    queueObs.observe(el, {
+                        childList: true,
+                    });
+                },
             });
             const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
                 if (addedNodes.length > 0 || removedNodes.length > 0) {
@@ -1011,14 +1022,46 @@ function initSiteEvents() {
                     emitSiteEvent("autoplayQueueChanged", target);
                 }
             });
-            autoplayObs.observe(document.querySelector("#side-panel ytmusic-player-queue #automix-contents"), {
-                childList: true,
+            onSelectorOld("#side-panel ytmusic-player-queue #automix-contents", {
+                listener: (el) => {
+                    autoplayObs.observe(el, {
+                        childList: true,
+                    });
+                },
+            });
+            //#SECTION player bar
+            let lastTitle = null;
+            let initialPlay = true;
+            globservers.playerBarInfo.addListener("yt-formatted-string.title", {
+                continuous: true,
+                listener: (titleElem) => {
+                    const oldTitle = lastTitle;
+                    const newTitle = titleElem.textContent;
+                    if (newTitle === lastTitle || !newTitle)
+                        return;
+                    lastTitle = newTitle;
+                    info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}" - initial play: ${initialPlay}`);
+                    emitSiteEvent("songTitleChanged", newTitle, oldTitle, initialPlay);
+                    initialPlay = false;
+                },
             });
             info("Successfully initialized SiteEvents observers");
             observers = observers.concat([
                 queueObs,
                 autoplayObs,
             ]);
+            //#SECTION other
+            let lastWatchId = null;
+            setInterval(() => {
+                if (location.pathname.startsWith("/watch")) {
+                    const newWatchId = new URL(location.href).searchParams.get("v");
+                    if (newWatchId && newWatchId !== lastWatchId) {
+                        info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`);
+                        emitSiteEvent("watchIdChanged", newWatchId, lastWatchId);
+                        lastWatchId = newWatchId;
+                    }
+                }
+            }, 200);
         }
         catch (err) {
             error("Couldn't initialize SiteEvents observers due to an error:\n", err);
@@ -1435,7 +1478,7 @@ function renderBody({ latestTag, changelogHtml, }) {
         verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.on("close", () => __awaiter(this, void 0, void 0, function* () {
             const config = getFeatures();
             config.versionCheck = !disableUpdateCheck;
-            yield saveFeatures(config);
+            yield setFeatures(config);
         }));
         const btnWrapper = document.createElement("div");
         btnWrapper.id = "bytm-version-notif-dialog-btns";
@@ -1880,7 +1923,7 @@ function addCfgMenu() {
             info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
             const featConf = JSON.parse(JSON.stringify(getFeatures()));
             featConf[key] = newVal;
-            yield saveFeatures(featConf);
+            yield setFeatures(featConf);
             // @ts-ignore
             (_h = (_g = featInfo[key]) === null || _g === void 0 ? void 0 : _g.change) === null || _h === void 0 ? void 0 : _h.call(_g, featConf);
             if (initConfig$1 !== JSON.stringify(featConf))
@@ -2703,7 +2746,7 @@ function addImportMenu() {
                 }
                 else if (parsed.formatVersion !== formatVersion)
                     return alert(t("import_error_wrong_format_version", formatVersion, parsed.formatVersion));
-                yield saveFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data));
+                yield setFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data));
                 if (confirm(t("import_success_confirm_reload"))) {
                     disableBeforeUnload();
                     return location.reload();
@@ -3175,11 +3218,11 @@ function addScrollToActiveBtn() {
                 const linkElem = document.createElement("div");
                 linkElem.id = "bytm-scroll-to-active-btn";
                 linkElem.tabIndex = 0;
-                linkElem.className = "ytmusic-player-bar bytm-generic-btn";
+                linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
                 linkElem.ariaLabel = linkElem.title = t("scroll_to_playing");
                 linkElem.role = "button";
                 const imgElem = document.createElement("img");
-                imgElem.className = "bytm-generic-btn-img";
+                imgElem.classList.add("bytm-generic-btn-img");
                 imgElem.src = yield getResourceUrl("icon-skip_to");
                 const scrollToActiveInteraction = () => {
                     const activeItem = document.querySelector("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
@@ -3198,6 +3241,100 @@ function addScrollToActiveBtn() {
             }),
         });
     });
+}
+//#MARKER thumbnail overlay
+/** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
+let invertOverlay = false;
+// TODO:
+// - use onSelectorOld to add the overlay if the /watch page isn't open on script init
+function initThumbnailOverlay() {
+    return __awaiter(this, void 0, void 0, function* () {
+        const behavior = getFeatures().thumbnailOverlayBehavior;
+        const toggleBtnShown = getFeatures().thumbnailOverlayToggleBtnShown;
+        if (behavior === "never" && !toggleBtnShown)
+            return;
+        const playerEl = document.querySelector("ytmusic-player#player");
+        if (!playerEl)
+            return warn("Couldn't find video player element while adding thumbnail overlay");
+        /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
+        const updateOverlayVisibility = () => __awaiter(this, void 0, void 0, function* () {
+            var _a, _b;
+            let showOverlay = behavior === "always";
+            const isVideo = (_b = (_a = document.querySelector("ytmusic-player")) === null || _a === void 0 ? void 0 : _a.hasAttribute("video-mode")) !== null && _b !== void 0 ? _b : false;
+            if (behavior === "videosOnly" && isVideo)
+                showOverlay = true;
+            else if (behavior === "songsOnly" && !isVideo)
+                showOverlay = true;
+            showOverlay = invertOverlay ? !showOverlay : showOverlay;
+            const overlayElem = document.querySelector("#bytm-thumbnail-overlay");
+            const thumbElem = document.querySelector("#bytm-thumbnail-overlay-img");
+            if (!overlayElem || !thumbElem)
+                return warn("Couldn't find thumbnail overlay element while checking visibility");
+            overlayElem.style.display = showOverlay ? "block" : "none";
+            thumbElem.ariaHidden = String(!showOverlay);
+            if (getFeatures().thumbnailOverlayToggleBtnShown) {
+                const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
+                const toggleBtnImgElem = document.querySelector("#bytm-thumbnail-overlay-toggle > img");
+                if (!toggleBtnElem || !toggleBtnImgElem)
+                    return warn("Couldn't find thumbnail overlay toggle button element while checking visibility");
+                toggleBtnImgElem.src = yield getResourceUrl(`icon-image${showOverlay ? "" : "_off"}`);
+                toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
+            }
+        });
+        const watchId = getWatchId();
+        if (!watchId)
+            return warn("Couldn't get watch ID while adding thumbnail overlay");
+        // overlay
+        const overlayElem = document.createElement("div");
+        overlayElem.id = "bytm-thumbnail-overlay";
+        overlayElem.classList.add("bytm-no-select");
+        overlayElem.style.display = "none";
+        const thumbImgElem = document.createElement("img");
+        thumbImgElem.id = "bytm-thumbnail-overlay-img";
+        thumbImgElem.role = "presentation";
+        thumbImgElem.ariaHidden = "true";
+        overlayElem.appendChild(thumbImgElem);
+        playerEl.appendChild(overlayElem);
+        siteEvents.on("watchIdChanged", () => __awaiter(this, void 0, void 0, function* () {
+            const watchId = getWatchId();
+            if (!watchId)
+                return warn("Couldn't get watch ID while updating thumbnail overlay");
+            const thumbUrl = yield getBestThumbnailUrl(watchId);
+            if (thumbUrl) {
+                const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle");
+                if (toggleBtnElem)
+                    toggleBtnElem.href = thumbUrl;
+                thumbImgElem.src = thumbUrl;
+            }
+            invertOverlay = false;
+            updateOverlayVisibility();
+        }));
+        // toggle button
+        if (toggleBtnShown) {
+            const toggleBtnElem = document.createElement("a");
+            toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
+            toggleBtnElem.role = "button";
+            toggleBtnElem.tabIndex = 0;
+            toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
+            onInteraction(toggleBtnElem, (e) => __awaiter(this, void 0, void 0, function* () {
+                if (e.shiftKey || (e instanceof MouseEvent && e.button === 3)) {
+                    const thumbUrl = yield getBestThumbnailUrl(watchId);
+                    if (thumbUrl)
+                        return GM.openInTab(thumbUrl);
+                }
+                invertOverlay = !invertOverlay;
+                updateOverlayVisibility();
+            }));
+            const imgElem = document.createElement("img");
+            imgElem.classList.add("bytm-generic-btn-img");
+            toggleBtnElem.appendChild(imgElem);
+            onSelectorOld(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", {
+                listener: (likeContainer) => UserUtils.insertAfter(likeContainer, toggleBtnElem),
+            });
+        }
+        updateOverlayVisibility();
+        log("Added thumbnail overlay");
+    });
 }//#MARKER beforeunload popup
 let beforeUnloadEnabled = true;
 /** Disables the popup before leaving the site */
@@ -3325,8 +3462,7 @@ function remSongUpdateEntry() {
     var _a, _b, _c;
     return __awaiter(this, void 0, void 0, function* () {
         if (location.pathname.startsWith("/watch")) {
-            const { searchParams } = new URL(location.href);
-            const watchID = searchParams.get("v");
+            const watchID = getWatchId();
             if (!watchID)
                 return;
             const songTime = (_a = yield getVideoTime()) !== null && _a !== void 0 ? _a : 0;
@@ -3794,7 +3930,7 @@ function fetchLyricsUrls(artist, song) {
 function createLyricsBtn(geniusUrl, hideIfLoading = true) {
     return __awaiter(this, void 0, void 0, function* () {
         const linkElem = document.createElement("a");
-        linkElem.className = "ytmusic-player-bar bytm-generic-btn";
+        linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
         linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
         if (geniusUrl)
             linkElem.href = geniusUrl;
@@ -3804,7 +3940,7 @@ function createLyricsBtn(geniusUrl, hideIfLoading = true) {
         linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
         linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
         const imgElem = document.createElement("img");
-        imgElem.className = "bytm-generic-btn-img";
+        imgElem.classList.add("bytm-generic-btn-img");
         imgElem.src = yield getResourceUrl("icon-lyrics");
         linkElem.appendChild(imgElem);
         return linkElem;
@@ -4111,6 +4247,26 @@ const featInfo = {
         default: true,
         enable: noopTODO,
     },
+    thumbnailOverlayBehavior: {
+        type: "select",
+        category: "layout",
+        options: () => [
+            { value: "never", label: t("thumbnail_overlay_behavior_never") },
+            { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
+            { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
+            { value: "always", label: t("thumbnail_overlay_behavior_always") },
+        ],
+        default: "never",
+        enable: noopTODO,
+        change: noopTODO,
+    },
+    thumbnailOverlayToggleBtnShown: {
+        type: "toggle",
+        category: "layout",
+        default: false,
+        enable: noopTODO,
+        disable: noopTODO,
+    },
     //#SECTION volume
     volumeSliderLabel: {
         type: "toggle",
@@ -4498,6 +4654,7 @@ const migrations = {
         "rememberSongTimeDuration", "rememberSongTimeReduction",
         "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs",
         "setInitialTabVolume", "initialTabVolumeLevel",
+        "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown",
     ], oldData),
     // TODO: once advanced filtering is fully implemented, clear cache on migration to fv6
     // 5 -> 6 (v1.3)
@@ -4551,7 +4708,7 @@ function getFeatures() {
     return bytmCfgStore.getData();
 }
 /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
-function saveFeatures(featureConf) {
+function setFeatures(featureConf) {
     const res = bytmCfgStore.setData(featureConf);
     emitSiteEvent("configChanged", bytmCfgStore.getData());
     info("Saved new feature config:", featureConf);
@@ -4587,7 +4744,7 @@ const globalFuncs = {
     t,
     tp,
     getFeatures: getFeaturesInterface,
-    saveFeatures,
+    saveFeatures: setFeatures,
     fetchLyricsUrlTop,
     getLyricsCacheEntry,
     sanitizeArtists,
@@ -4801,6 +4958,36 @@ function arrayWithSeparators(array, separator = ", ", lastSeparator) {
     else
         return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)}`;
 }
+/** Returns the watch ID of the current video or null if not on a video page */
+function getWatchId() {
+    const { searchParams, pathname } = new URL(location.href);
+    return pathname.includes("/watch") ? searchParams.get("v") : null;
+}
+/** Returns the thumbnail URL for a video with either a given quality identifier or index */
+function getThumbnailUrl(watchId, qualityOrIndex = "hqdefault") {
+    return `https://i.ytimg.com/vi/${watchId}/${qualityOrIndex}.jpg`;
+}
+/** Returns the best available thumbnail URL for a video with the given watch ID */
+function getBestThumbnailUrl(watchId) {
+    return __awaiter(this, void 0, void 0, function* () {
+        const priorityList = ["maxresdefault", "sddefault", 0];
+        for (const quality of priorityList) {
+            const url = getThumbnailUrl(watchId, quality);
+            let response;
+            try {
+                response = yield UserUtils.fetchAdvanced(url, { method: "HEAD", timeout: 5000 });
+            }
+            catch (e) {
+            }
+            if (response === null || response === void 0 ? void 0 : response.ok)
+                return url;
+        }
+    });
+}
+/** Copies a JSON-serializable object */
+function reserialize(data) {
+    return JSON.parse(JSON.stringify(data));
+}
 //#SECTION resources
 /**
  * Returns the URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl)
@@ -5065,7 +5252,7 @@ function addWelcomeMenu() {
             const selectedLocale = localeSelectElem.value;
             const feats = Object.assign({}, getFeatures());
             feats.locale = selectedLocale;
-            saveFeatures(feats);
+            setFeatures(feats);
             yield initTranslations(selectedLocale);
             setLocale(selectedLocale);
             retranslateWelcomeMenu();
@@ -5285,7 +5472,7 @@ function onDomLoad() {
                     yield showWelcomeMenu();
                     yield GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }));
                 }
-                observers$1.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
+                globservers.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
                     listener: addConfigMenuOption,
                 });
                 if (features.arrowKeySupport)
@@ -5310,6 +5497,7 @@ function onDomLoad() {
                     ftInit.push(fixSpacing());
                 if (features.scrollToActiveSongBtn)
                     ftInit.push(addScrollToActiveBtn());
+                ftInit.push(initThumbnailOverlay());
                 ftInit.push(initVolumeFeatures());
             }
             if (["ytm", "yt"].includes(domain)) {
@@ -6534,7 +6722,7 @@ hr {
     border: 1px solid transparent;
   }
   20% {
-    border: 1px solid #727272;
+    border: 1px solid #808080;
   }
   100% {
     border: 1px solid transparent;
@@ -6720,6 +6908,30 @@ yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer {
   padding: 4px;
 }
 
+/** #MARKER thumbnail */
+
+#bytm-thumbnail-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: none;
+  background-color: #030303;
+  z-index: 0;
+}
+
+#bytm-thumbnail-overlay-img {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+ytmusic-player#player #bezel {
+  z-index: 1;
+}
+
 /* #MARKER queue buttons */
 
 #side-panel ytmusic-player-queue-item .song-info.ytmusic-player-queue-item {
@@ -6851,6 +7063,13 @@ function registerDevMenuCommands() {
             location.reload();
         }
     }), "r");
+    GM.registerMenuCommand("Fix missing config values", () => __awaiter(this, void 0, void 0, function* () {
+        const oldFeats = reserialize(getFeatures());
+        yield setFeatures(Object.assign(Object.assign({}, defaultData), getFeatures()));
+        console.log("Fixed missing config values.\nFrom:", oldFeats, "\n\nTo:", getFeatures());
+        if (confirm("All missing or invalid config values were set to their default values.\nReload the page now?"))
+            location.reload();
+    }));
     GM.registerMenuCommand("List GM values in console with decompression", () => __awaiter(this, void 0, void 0, function* () {
         const keys = yield GM.listValues();
         console.log(`GM values (${keys.length}):`);
@@ -6924,7 +7143,7 @@ function registerDevMenuCommands() {
     GM.registerMenuCommand("List active selector listeners in console", () => __awaiter(this, void 0, void 0, function* () {
         const lines = [];
         let listenersAmt = 0;
-        for (const [obsName, obs] of Object.entries(observers$1)) {
+        for (const [obsName, obs] of Object.entries(globservers)) {
             const listeners = obs.getAllListeners();
             lines.push(`- "${obsName}" (${listeners.size} listeners):`);
             [...listeners].forEach(([k, v]) => {
@@ -6935,7 +7154,7 @@ function registerDevMenuCommands() {
                 });
             });
         }
-        console.log(`Showing currently active listeners for ${Object.keys(observers$1).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
+        console.log(`Showing currently active listeners for ${Object.keys(globservers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
     }), "s");
     GM.registerMenuCommand("Compress value", () => __awaiter(this, void 0, void 0, function* () {
         const input = prompt("Enter the value to compress.\nSee console for output.");

+ 2 - 1
src/config.ts

@@ -52,6 +52,7 @@ export const migrations: DataMigrationsDict = {
     "rememberSongTimeDuration", "rememberSongTimeReduction",
     "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs",
     "setInitialTabVolume", "initialTabVolumeLevel",
+    "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown",
   ], oldData),
   // TODO: once advanced filtering is fully implemented, clear cache on migration to fv6
   // 5 -> 6 (v1.3)
@@ -113,7 +114,7 @@ export function getFeatures() {
 }
 
 /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
-export function saveFeatures(featureConf: FeatureConfig) {
+export function setFeatures(featureConf: FeatureConfig) {
   const res = bytmCfgStore.setData(featureConf);
   emitSiteEvent("configChanged", bytmCfgStore.getData());
   info("Saved new feature config:", featureConf);

+ 2 - 2
src/dialogs/versionNotif.ts

@@ -1,7 +1,7 @@
 import { host, scriptInfo } from "../constants";
 import { getChangelogMd, onInteraction, parseMarkdown, t } from "../utils";
 import { BytmDialog, createToggleInput } from "../components";
-import { getFeatures, saveFeatures } from "../config";
+import { getFeatures, setFeatures } from "../config";
 import pkg from "../../package.json" assert { type: "json" };
 
 let verNotifDialog: BytmDialog | null = null;
@@ -125,7 +125,7 @@ async function renderBody({
   verNotifDialog?.on("close", async () => {
     const config = getFeatures();
     config.versionCheck = !disableUpdateCheck;
-    await saveFeatures(config);
+    await setFeatures(config);
   });
 
   const btnWrapper = document.createElement("div");

+ 2 - 3
src/features/behavior.ts

@@ -1,5 +1,5 @@
 import { clamp, pauseFor } from "@sv443-network/userutils";
-import { domLoaded, error, getDomain, getVideoTime, info, log, onSelectorOld, videoSelector, waitVideoElementReady } from "../utils";
+import { domLoaded, error, getDomain, getVideoTime, getWatchId, info, log, onSelectorOld, videoSelector, waitVideoElementReady } from "../utils";
 import { LogLevel } from "../types";
 import { getFeatures } from "src/config";
 
@@ -158,8 +158,7 @@ async function restoreSongTime() {
 /** Updates the currently playing song's entry in GM storage */
 async function remSongUpdateEntry() {
   if(location.pathname.startsWith("/watch")) {
-    const { searchParams } = new URL(location.href);
-    const watchID = searchParams.get("v");
+    const watchID = getWatchId();
     if(!watchID)
       return;
 

+ 20 - 0
src/features/index.ts

@@ -108,6 +108,26 @@ export const featInfo = {
     default: true,
     enable: noopTODO,
   },
+  thumbnailOverlayBehavior: {
+    type: "select",
+    category: "layout",
+    options: () => [
+      { value: "never", label: t("thumbnail_overlay_behavior_never") },
+      { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
+      { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
+      { value: "always", label: t("thumbnail_overlay_behavior_always") },
+    ],
+    default: "never",
+    enable: noopTODO,
+    change: noopTODO,
+  },
+  thumbnailOverlayToggleBtnShown: {
+    type: "toggle",
+    category: "layout",
+    default: false,
+    enable: noopTODO,
+    disable: noopTODO,
+  },
 
   //#SECTION volume
   volumeSliderLabel: {

+ 25 - 1
src/features/layout.css

@@ -37,7 +37,7 @@
     border: 1px solid transparent;
   }
   20% {
-    border: 1px solid #727272;
+    border: 1px solid #808080;
   }
   100% {
     border: 1px solid transparent;
@@ -222,3 +222,27 @@ yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer {
 #bytm-scroll-to-active-btn .bytm-generic-btn-img {
   padding: 4px;
 }
+
+/** #MARKER thumbnail */
+
+#bytm-thumbnail-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: none;
+  background-color: #030303;
+  z-index: 0;
+}
+
+#bytm-thumbnail-overlay-img {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+ytmusic-player#player #bezel {
+  z-index: 1;
+}

+ 122 - 3
src/features/layout.ts

@@ -1,8 +1,10 @@
 import { addGlobalStyle, addParent, autoPlural, fetchAdvanced, insertAfter, pauseFor } from "@sv443-network/userutils";
+import { getFeatures } from "../config";
+import { error, getResourceUrl, log, onSelectorOld, warn, t, onInteraction, getWatchId, getBestThumbnailUrl } from "../utils";
 import { scriptInfo } from "../constants";
-import { error, getResourceUrl, log, onSelectorOld, warn, t, onInteraction } from "../utils";
 import { openCfgMenu } from "../menu/menu_old";
 import "./layout.css";
+import { siteEvents } from "src/siteEvents";
 
 //#MARKER BYTM-Config buttons
 
@@ -359,12 +361,12 @@ export async function addScrollToActiveBtn() {
       const linkElem = document.createElement("div");
       linkElem.id = "bytm-scroll-to-active-btn";
       linkElem.tabIndex = 0;
-      linkElem.className = "ytmusic-player-bar bytm-generic-btn";
+      linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
       linkElem.ariaLabel = linkElem.title = t("scroll_to_playing");
       linkElem.role = "button";
 
       const imgElem = document.createElement("img");
-      imgElem.className = "bytm-generic-btn-img";
+      imgElem.classList.add("bytm-generic-btn-img");
       imgElem.src = await getResourceUrl("icon-skip_to");
 
       const scrollToActiveInteraction = () => {
@@ -387,3 +389,120 @@ export async function addScrollToActiveBtn() {
     },
   });
 }
+
+//#MARKER thumbnail overlay
+
+/** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */
+let invertOverlay = false;
+
+// TODO:
+// - use onSelectorOld to add the overlay if the /watch page isn't open on script init
+
+export async function initThumbnailOverlay() {
+  const behavior = getFeatures().thumbnailOverlayBehavior;
+  const toggleBtnShown = getFeatures().thumbnailOverlayToggleBtnShown;
+  if(behavior === "never" && !toggleBtnShown)
+    return;
+
+  const playerEl = document.querySelector<HTMLElement>("ytmusic-player#player");
+
+  if(!playerEl)
+    return warn("Couldn't find video player element while adding thumbnail overlay");
+
+  /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */
+  const updateOverlayVisibility = async () => {
+    let showOverlay = behavior === "always";
+    const isVideo = document.querySelector<HTMLElement>("ytmusic-player")?.hasAttribute("video-mode") ?? false;
+
+    if(behavior === "videosOnly" && isVideo)
+      showOverlay = true;
+    else if(behavior === "songsOnly" && !isVideo)
+      showOverlay = true;
+
+    showOverlay = invertOverlay ? !showOverlay : showOverlay;
+
+    const overlayElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay");
+    const thumbElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay-img");
+    if(!overlayElem || !thumbElem)
+      return warn("Couldn't find thumbnail overlay element while checking visibility");
+
+    overlayElem.style.display = showOverlay ? "block" : "none";
+    thumbElem.ariaHidden = String(!showOverlay);
+
+    if(getFeatures().thumbnailOverlayToggleBtnShown) {
+      const toggleBtnElem = document.querySelector<HTMLImageElement>("#bytm-thumbnail-overlay-toggle");
+      const toggleBtnImgElem = document.querySelector<HTMLImageElement>("#bytm-thumbnail-overlay-toggle > img");
+      if(!toggleBtnElem || !toggleBtnImgElem)
+        return warn("Couldn't find thumbnail overlay toggle button element while checking visibility");
+
+      toggleBtnImgElem.src = await getResourceUrl(`icon-image${showOverlay ? "" : "_off"}` as "icon-image" | "icon-image_off");
+      toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`);
+    }
+  };
+
+  const watchId = getWatchId();
+  if(!watchId)
+    return warn("Couldn't get watch ID while adding thumbnail overlay");
+
+  // overlay
+  const overlayElem = document.createElement("div");
+  overlayElem.id = "bytm-thumbnail-overlay";
+  overlayElem.classList.add("bytm-no-select");
+  overlayElem.style.display = "none";
+
+  const thumbImgElem = document.createElement("img");
+  thumbImgElem.id = "bytm-thumbnail-overlay-img";
+  thumbImgElem.role = "presentation";
+  thumbImgElem.ariaHidden = "true";
+
+  overlayElem.appendChild(thumbImgElem);
+  playerEl.appendChild(overlayElem);
+
+  siteEvents.on("watchIdChanged", async () => {
+    const watchId = getWatchId();
+    if(!watchId)
+      return warn("Couldn't get watch ID while updating thumbnail overlay");
+
+    const thumbUrl = await getBestThumbnailUrl(watchId);
+    if(thumbUrl) {
+      const toggleBtnElem = document.querySelector<HTMLAnchorElement>("#bytm-thumbnail-overlay-toggle");
+      if(toggleBtnElem)
+        toggleBtnElem.href = thumbUrl;
+      thumbImgElem.src = thumbUrl;
+    }
+    invertOverlay = false;
+    updateOverlayVisibility();
+  });
+
+  // toggle button
+  if(toggleBtnShown) {
+    const toggleBtnElem = document.createElement("a");
+    toggleBtnElem.id = "bytm-thumbnail-overlay-toggle";
+    toggleBtnElem.role = "button";
+    toggleBtnElem.tabIndex = 0;
+    toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select");
+
+    onInteraction(toggleBtnElem, async (e) => {
+      if(e.shiftKey || (e instanceof MouseEvent && e.button === 3)) {
+        const thumbUrl = await getBestThumbnailUrl(watchId);
+        if(thumbUrl)
+          return GM.openInTab(thumbUrl);
+      }
+      invertOverlay = !invertOverlay;
+      updateOverlayVisibility();
+    });
+
+    const imgElem = document.createElement("img");
+    imgElem.classList.add("bytm-generic-btn-img");
+
+    toggleBtnElem.appendChild(imgElem);
+
+    onSelectorOld(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", {
+      listener: (likeContainer) => insertAfter(likeContainer, toggleBtnElem),
+    });
+  }
+
+  updateOverlayVisibility();
+
+  log("Added thumbnail overlay");
+}

+ 2 - 2
src/features/lyrics.ts

@@ -373,7 +373,7 @@ export async function fetchLyricsUrls(artist: string, song: string): Promise<Omi
 /** Creates the base lyrics button element */
 export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) {
   const linkElem = document.createElement("a");
-  linkElem.className = "ytmusic-player-bar bytm-generic-btn";
+  linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
   linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
   if(geniusUrl)
     linkElem.href = geniusUrl;
@@ -384,7 +384,7 @@ export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true)
   linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
 
   const imgElem = document.createElement("img");
-  imgElem.className = "bytm-generic-btn-img";
+  imgElem.classList.add("bytm-generic-btn-img");
   imgElem.src = await getResourceUrl("icon-lyrics");
 
   linkElem.appendChild(imgElem);

+ 17 - 7
src/index.ts

@@ -1,17 +1,17 @@
 import { addGlobalStyle, compress, decompress, type Stringifiable } from "@sv443-network/userutils";
-import { domLoaded, initOnSelector, warn } from "./utils";
-import { clearConfig, getFeatures, initConfig } from "./config";
+import { domLoaded, initOnSelector, reserialize, warn } from "./utils";
+import { clearConfig, defaultData as defaultFeatData, getFeatures, initConfig, setFeatures } from "./config";
 import { buildNumber, compressionFormat, defaultLogLevel, mode, scriptInfo } from "./constants";
 import { error, getDomain, info, getSessionId, log, setLogLevel, initTranslations, setLocale } from "./utils";
 import { initSiteEvents } from "./siteEvents";
 import { emitInterface, initInterface, initPlugins } from "./interface";
 import { addWelcomeMenu, showWelcomeMenu } from "./menu/welcomeMenu";
-import { initObservers, observers } from "./observers";
+import { initObservers, globservers } from "./observers";
 import {
   // layout
   addWatermark, removeUpgradeTab,
   removeShareTrackingParam, fixSpacing,
-  addScrollToActiveBtn,
+  addScrollToActiveBtn, initThumbnailOverlay,
   // volume
   initVolumeFeatures,
   // song lists
@@ -145,7 +145,7 @@ async function onDomLoad() {
         await GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }));
       }
 
-      observers.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
+      globservers.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
         listener: addConfigMenuOption,
       });
 
@@ -182,6 +182,8 @@ async function onDomLoad() {
       if(features.scrollToActiveSongBtn)
         ftInit.push(addScrollToActiveBtn());
 
+      ftInit.push(initThumbnailOverlay());
+
       ftInit.push(initVolumeFeatures());
     }
 
@@ -315,6 +317,14 @@ function registerDevMenuCommands() {
     }
   }, "r");
 
+  GM.registerMenuCommand("Fix missing config values", async () => {
+    const oldFeats = reserialize(getFeatures());
+    await setFeatures({ ...defaultFeatData, ...getFeatures() });
+    console.log("Fixed missing config values.\nFrom:", oldFeats, "\n\nTo:", getFeatures());
+    if(confirm("All missing or invalid config values were set to their default values.\nReload the page now?"))
+      location.reload();
+  });
+
   GM.registerMenuCommand("List GM values in console with decompression", async () => {
     const keys = await GM.listValues();
     console.log(`GM values (${keys.length}):`);
@@ -397,7 +407,7 @@ function registerDevMenuCommands() {
   GM.registerMenuCommand("List active selector listeners in console", async () => {
     const lines = [] as string[];
     let listenersAmt = 0;
-    for(const [obsName, obs] of Object.entries(observers)) {
+    for(const [obsName, obs] of Object.entries(globservers)) {
       const listeners = obs.getAllListeners();
       lines.push(`- "${obsName}" (${listeners.size} listeners):`);
       [...listeners].forEach(([k, v]) => {
@@ -408,7 +418,7 @@ function registerDevMenuCommands() {
         });
       });
     }
-    console.log(`Showing currently active listeners for ${Object.keys(observers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
+    console.log(`Showing currently active listeners for ${Object.keys(globservers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
   }, "s");
 
   GM.registerMenuCommand("Compress value", async () => {

+ 2 - 2
src/interface.ts

@@ -2,7 +2,7 @@ import * as UserUtils from "@sv443-network/userutils";
 import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants";
 import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale, info, error } from "./utils";
 import { addSelectorListener } from "./observers";
-import { getFeatures, saveFeatures } from "./config";
+import { getFeatures, setFeatures } from "./config";
 import { featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong, type LyricsCache } from "./features";
 import { allSiteEvents, siteEvents, type SiteEventsMap } from "./siteEvents";
 import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable, type PluginEventMap, type PluginItem } from "./types";
@@ -65,7 +65,7 @@ const globalFuncs = {
   t,
   tp,
   getFeatures: getFeaturesInterface,
-  saveFeatures,
+  saveFeatures: setFeatures,
   fetchLyricsUrlTop,
   getLyricsCacheEntry,
   sanitizeArtists,

+ 3 - 3
src/menu/menu_old.ts

@@ -1,5 +1,5 @@
 import { compress, decompress, debounce, isScrollable } from "@sv443-network/userutils";
-import { defaultData, getFeatures, migrations, saveFeatures, setDefaultFeatures } from "../config";
+import { defaultData, getFeatures, migrations, setFeatures, setDefaultFeatures } from "../config";
 import { buildNumber, compressionFormat, host, mode, scriptInfo } from "../constants";
 import { featInfo, disableBeforeUnload } from "../features/index";
 import { error, getResourceUrl, info, log, resourceToHTMLString, warn, getLocale, hasKey, initTranslations, setLocale, t, compressionSupported, getChangelogHtmlWithDetails, arrayWithSeparators, tp, type TrKey, onInteraction } from "../utils";
@@ -221,7 +221,7 @@ async function addCfgMenu() {
 
     featConf[key] = newVal as never;
 
-    await saveFeatures(featConf);
+    await setFeatures(featConf);
 
     // @ts-ignore
     featInfo[key]?.change?.(featConf);
@@ -1215,7 +1215,7 @@ async function addImportMenu() {
       else if(parsed.formatVersion !== formatVersion)
         return alert(t("import_error_wrong_format_version", formatVersion, parsed.formatVersion));
 
-      await saveFeatures({ ...getFeatures(), ...parsed.data });
+      await setFeatures({ ...getFeatures(), ...parsed.data });
 
       if(confirm(t("import_success_confirm_reload"))) {
         disableBeforeUnload();

+ 2 - 2
src/menu/welcomeMenu.ts

@@ -1,5 +1,5 @@
 import { getResourceUrl, warn, type TrLocale, initTranslations, setLocale, t } from "../utils";
-import { getFeatures, saveFeatures } from "../config";
+import { getFeatures, setFeatures } from "../config";
 import { siteEvents } from "../siteEvents";
 import { scriptInfo } from "../constants";
 import { openCfgMenu, openChangelogMenu } from "./menu_old";
@@ -114,7 +114,7 @@ export async function addWelcomeMenu() {
     const selectedLocale = localeSelectElem.value;
     const feats = Object.assign({}, getFeatures());
     feats.locale = selectedLocale as TrLocale;
-    saveFeatures(feats);
+    setFeatures(feats);
 
     await initTranslations(selectedLocale as TrLocale);
     setLocale(selectedLocale as TrLocale);

+ 6 - 2
src/types.ts

@@ -5,7 +5,7 @@ import type { addSelectorListener } from "./observers";
 import type resources from "../assets/resources.json";
 import type locales from "../assets/locales.json";
 import type { getResourceUrl, getSessionId, getVideoTime, TrLocale, t, tp } from "./utils";
-import type { getFeatures, saveFeatures } from "./config";
+import type { getFeatures, setFeatures } from "./config";
 import type { SiteEventsMap } from "./siteEvents";
 
 /** Custom CLI args passed to rollup */
@@ -200,7 +200,7 @@ export type InterfaceFunctions = {
   /** Returns the current feature configuration */
   getFeatures: typeof getFeatures;
   /** Overwrites the feature configuration with the provided one */
-  saveFeatures: typeof saveFeatures;
+  saveFeatures: typeof setFeatures;
 };
 
 //#MARKER features
@@ -316,6 +316,10 @@ export interface FeatureConfig {
   fixSpacing: boolean;
   /** Remove the \"Upgrade\" / YT Music Premium tab */
   removeUpgradeTab: boolean;
+  /** Where to show a thumbnail overlay over the video element and whether to show it at all */
+  thumbnailOverlayBehavior: "never" | "videosOnly" | "songsOnly" | "always";
+  /** Whether to show a button to toggle the thumbnail overlay in the media controls */
+  thumbnailOverlayToggleBtnShown: boolean;
 
   //#SECTION volume
   /** Add a percentage label to the volume slider */