Repository: exadel-inc/esl
Pull request #2942 (2025-02-18 15:51:21)

epic(esl-media): rework commands API, add possibility to restrict play, handle containers
Closes: #2897
Closes: #2712


Patch:
diff --git a/site/views/dev/esl-media-regression.njk b/site/views/dev/esl-media-regression.njk
new file mode 100644
index 000000000..4832f9279
--- /dev/null
+++ b/site/views/dev/esl-media-regression.njk
@@ -0,0 +1,166 @@
+---
+layout: content
+title: ESL Media (Regression)
+name: ESL Media (Regression)
+tags: [dev]
+---
+
+{% import 'lorem.njk' as lorem %}
+
+<section>
+  <h2>Simple instances</h2>
+
+  <div class="row">
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Simple instance (video, loop, muted, controls, no autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media controls loop muted media-src="/assets/media/video_1.mp4"></esl-media>
+      </div>
+    </div>
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Simple instance (video, loop, muted, with autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media loop muted autoplay media-src="/assets/media/video_1.mp4"></esl-media>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Simple instance (youtube, no autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media media-src="https://www.youtube.com/watch?v=KkOsTHMTsfI"></esl-media>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Simple instance (iframe, no autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media media-src="https://player.vimeo.com/video/1084537"></esl-media>
+      </div>
+    </div>
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Simple instance (iframe, with autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media autoplay media-src="https://player.vimeo.com/video/1084537"></esl-media>
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Lazy instance (video, loop, muted, no autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media lazy loop muted media-src="/assets/media/video_1.mp4"></esl-media>
+      </div>
+    </div>
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Lazy instance (video, loop, muted, with autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media lazy loop muted autoplay media-src="/assets/media/video_1.mp4"></esl-media>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Play in viewport (video, loop, muted, no autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media play-in-viewport loop muted media-src="/assets/media/video_1.mp4"></esl-media>
+      </div>
+    </div>
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Lazy instance play in viewport (video, loop, muted, with autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media lazy play-in-viewport loop muted autoplay media-src="/assets/media/video_1.mp4"></esl-media>
+      </div>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Lazy youtube (loop, muted, no autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media lazy loop muted media-src="https://www.youtube.com/watch?v=KkOsTHMTsfI"></esl-media>
+      </div>
+    </div>
+    <div class="col-12 col-md-6" log-scope>
+      <h3>Lazy iframe (loop, muted, with autoplay):</h3>
+      <div class="img-container img-container-16-9">
+        <esl-media lazy loop muted autoplay media-src="https://player.vimeo.com/video/1084537"></esl-media>
+      </div>
+    </div>
+  </div>
+</section>
+
+<section>
+  <h2>Inside Toggleable</h2>
+
+  <esl-trigger target="#toggleable1" mode="toggle" a11y-label-active a11y-label-inactive="expand" class="btn btn-sec-blue">Toggle</esl-trigger>
+  <esl-panel id="toggleable1">
+    <div class="row">
+      <div class="col-12 col-md-6" log-scope>
+        <h3>Lazy instance (video, loop, muted, no autoplay):</h3>
+        <div class="img-container img-container-16-9">
+          <esl-media lazy loop muted media-src="/assets/media/video_1.mp4"></esl-media>
+        </div>
+      </div>
+      <div class="col-12 col-md-6" log-scope>
+        <h3>Simple instance (video, loop, muted, with autoplay):</h3>
+        <div class="img-container img-container-16-9">
+          <esl-media loop muted autoplay media-src="/assets/media/video_1.mp4"></esl-media>
+        </div>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-12 col-md-6" log-scope>
+        <h3>Simple instance (youtube, no autoplay):</h3>
+        <div class="img-container img-container-16-9">
+          <esl-media media-src="https://www.youtube.com/watch?v=KkOsTHMTsfI"></esl-media>
+        </div>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-12 col-md-6" log-scope>
+        <h3>Lazy instance (youtube, no autoplay):</h3>
+        <div class="img-container img-container-16-9">
+          <esl-media media-src="https://www.youtube.com/watch?v=KkOsTHMTsfI"></esl-media>
+        </div>
+      </div>
+      <div class="col-12 col-md-6" log-scope>
+        <h3>Simple instance (iframe, with autoplay):</h3>
+        <div class="img-container img-container-16-9">
+          <esl-media autoplay media-src="https://player.vimeo.com/video/1084537"></esl-media>
+        </div>
+      </div>
+    </div>
+  </esl-panel>
+</section>
+
+<script>
+  function logEvent(e) {
+    const scope = e.target.closest('[log-scope]');
+    if (!scope) return;
+    let log = scope.querySelector('ul.log');
+    if (!log) {
+      log = document.createElement('ul');
+      log.classList.add('log', 'alert', 'alert-info');
+      scope.append(log);
+    }
+    const item = document.createElement('li');
+    item.textContent = `[${new Date().toISOString()}] Event: ${e.type} - Detail: ${JSON.stringify(e.detail)}`;
+    log.append(item);
+  }
+  document.addEventListener('esl:media:ready', logEvent);
+  document.addEventListener('esl:media:play', logEvent);
+  document.addEventListener('esl:media:before:play', logEvent);
+  document.addEventListener('esl:media:paused', logEvent);
+  document.addEventListener('esl:media:managedpause', logEvent);
+</script>
+<style>
+  ul.log {
+    max-height: 200px;
+    overflow-y: auto;
+    & > li {
+      margin-inline-start: 15px;
+    }
+  }
+</style>
diff --git a/site/views/examples/embedded-media.njk b/site/views/examples/embedded-media.njk
index 17007790e..e3fe02d32 100644
--- a/site/views/examples/embedded-media.njk
+++ b/site/views/examples/embedded-media.njk
@@ -22,11 +22,11 @@ aside:
     </div>
 
     <div class="btn-group btn-group-centered">
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
       </button>
     </div>
   </script>
@@ -45,11 +45,11 @@ aside:
     </esl-media>
 
     <div class="btn-group btn-group-centered">
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
       </button>
     </div>
   </script>
@@ -72,11 +72,11 @@ aside:
     </div>
 
     <div class="btn-group btn-group-centered">
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
       </button>
     </div>
   </script>
@@ -102,11 +102,11 @@ aside:
     </div>
 
     <div class="btn-group btn-group-centered">
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'pause'}">Pause
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
       </button>
     </div>
 
@@ -140,9 +140,9 @@ aside:
     </div>
 
     <div class="btn-group btn-group-centered">
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'play'}">Play
       </button>
-      <button class="btn btn-sec-blue" esl-media-contol="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
+      <button class="btn btn-sec-blue" esl-media-control="{target: '::closest(uip-root)::find(.esl-media)', action: 'stop'}">Stop
       </button>
     </div>
   </script>
@@ -169,9 +169,8 @@ aside:
                  media-type="video"
                  media-src="{{ '/assets/media/video_1.mp4' | url }}"
                  load-condition="(min-width: 600px)"
-                 load-cls-target="::parent(.img-container)"
-                 load-cls-accepted="show-video"
-                 load-cls-declined="hide-video"
+                 load-condition-class="show-video !hide-video"
+                 load-condition-class-target="::parent(.img-container)"
                  controls
                  tabindex="0">
       </esl-media>
@@ -213,6 +212,7 @@ aside:
     <uip-text-setting label="Start time:" attribute="start-time"></uip-text-setting>
     <uip-bool-setting label="Muted" attribute="muted"></uip-bool-setting>
     <uip-bool-setting label="Autoplay" attribute="autoplay"></uip-bool-setting>
+    <uip-bool-setting label="Play in viewport" attribute="play-in-viewport"></uip-bool-setting>
   </uip-settings>
   <uip-editor label="Source code (HTML)" collapsible copy></uip-editor>
   <uip-editor source="js" label="Source code (JS)" collapsible collapsed copy></uip-editor>
diff --git a/src/modules/esl-media/README.md b/src/modules/esl-media/README.md
index 0e22b8434..9d404fc40 100644
--- a/src/modules/esl-media/README.md
+++ b/src/modules/esl-media/README.md
@@ -1,8 +1,8 @@
 # [ESL](../../../) Media
 
-Version: *1.4.0*
+Version: *2.0.0*
 
-Authors: *Alexey Stsefanovich (ala'n)*, *Yuliya Adamskaya*, *Julia Murashko*, *Natallia Harshunova*
+Authors: *Alexey Stsefanovich (ala'n)*, *Yuliya Adamskaya*, *Julia Murashko*, *Natallia Harshunova*, *Anastasia Lesun*, *Feoktist Shovchko*
 
 <a name="intro"></a>
 
@@ -11,28 +11,84 @@ using a single tag as well as work with external providers using simple native-l
 
 ---
 
-### Supported Features:
- 
- - extendable `MediaProviders` implementation for different media types. Out-of-the-box support of:
-   - HTMLAudio (`audio` type)
-   - HTMLVideo (`video` type)
-   - Youtube (`youtube` type)
-   - Brightcove (`brightcove` type)
-   - Abstract Iframe (`iframe` type)
- 
- - `load-condition` - restriction to load esl-media. Uses [ESLMediaQuery](../esl-media-query/README.md) syntax.
- 
- - `play-in-viewport` - feature that restricts active state to only visible components on the page.
- 
- - `manual initialization` - component will not be initialized until `disable` marker is removed.
- 
- - `group manager` - to allow single active player in group restriction.
- 
- - `fill mode` - feature that allows managing player rendering option in bounds of given element area.
- 
- - state change events (`esl:media:load`, `esl:media:error`, `esl:media:play`, etc).
+## Supported Features
+
+Provides 'HTMLMedia like' API that is safe and will be executed after real API is ready.
+
+### ESLMedia supports different media providers
+
+#### Out of the box providers
+ESLMedia module provides out-of-the-box support for the following media providers:
+    - `audio` type - provided by AudioProvider which renders native HTMLAudioElement
+    - `video` type - provided by VideoProvider which renders native HTMLVideoElement
+    
+    - `youtube` type - provided by YoutubeProvider which renders Youtube video player
+    - `brightcove` type - provided by BrightcoveProvider which renders videojs video player (with Brightcove plugin applied)
+
+    - `iframe` type - abstract fallback provider (IframeProvider) which renders native HTMLIFrameElement. Does not support control commands and in pause state excludes iframe from the DOM.
  
- - provides 'HTMLMedia like' API that is safe and will be executed after real API is ready.
+#### Custom providers
+ESLMedia module provides an ability to add custom media providers.
+You should use `BaseProvider` class to create a custom provider.
+BaseProvider class requires to implement all abstract methods and properties.
+Also note that a custom provider should call following hooks manually
+ - `this.component._onReady()` - to notify ESLMedia that provider is ready
+ - `this.component._onError()` - to notify ESLMedia that provider is failed to load
+ - `this.component._onPlay()` - to notify ESLMedia that provider is started to play
+ - `this.component._onPaused()` - to notify ESLMedia that provider is paused
+ - `this.component._onEnded()` - to notify ESLMedia that provider is ended
+ - `this.component._onDetach()` - to notify ESLMedia that provider is detached (in case of custom `unbind` implementation)
+During provider binding `this._ready` mixin property should be initialized with Promise.
+
+### ESLMedia supports wide range of initialization and state management features
+
+#### Lazy & Manual loading
+ESLMedia supports lazy attribute that provides two modes of custom media loading:
+- `auto` - initializes media when it becomes visible in the browser viewport or is in close proximity to it.
+- `manual` - blocks media initialization until the attribute is removed manually from the consumer's code.
+
+#### Load condition
+ESLMedia supports `load-condition` attribute that restricts loading of media resources based on the given condition.
+Attribute uses [ESLMediaQuery](../esl-media-query/README.md) syntax.
+
+#### Play in viewport restriction
+In case of `play-in-viewport` attribute is set, ESLMedia will automatically stop media playback when it is out of the viewport area,
+and resume playback when it returns to the viewport.
+
+#### Group restriction
+ESLMedia supports `group` attribute that restricts the number of active media players in the given group.
+
+#### Container control
+ESLMedia supports `ESLToggleable` container state observation.
+When the container is opened, the media player with autoplay attribute allowed to be started.
+When the container is closed, the media player inside it will be automatically paused.
+
+#### Initialization hook and instance manager
+ESLMedia dispatches `esl:media:before:play` cancelable event in the following cases:
+- when the provider requested to play automatically or manually by user
+- when the provider initialized with `autoplay` attribute
+If `esl:media:before:play` event is canceled, the provider won't start playing.
+
+In addition ESLMedia.property.manager holds `ESLMediaManager` instance (singleton by default) that 
+controls all media instances that are referencing the same manager instance.
+The `esl:media:managedaction` event could be dispatched on the window to call manager `resume`/`suspend` methods indirectly.
+
+#### Commands initiator control
+ESLMedia commands divided to user-related and system-related.
+User-related commands have higher priority and override system-related commands.
+All automatic features like `play-in-viewport` and `autoplay` utilize system-related commands, so manual user interaction will have higher priority.
+
+#### State attributes, classes and events
+ESLMedia dispatches a set of events to notify about media state changes.
+Also, ESLMedia provides attributes to reflect media state and additional classes that could be attached via
+`load-condition-class` attribute with support of ESLClassUtils syntax and 
+`load-condition-class-target` attribute with support of ESLTraversingQuery syntax.
+
+
+### Fill mode support
+ - Fill mode, declared by `fill` attribute - feature that allows managing player rendering option in bounds of given element area.
+
+## API:
 
 ### Attributes:
 
@@ -67,14 +123,14 @@ using a single tag as well as work with external providers using simple native-l
  
  - `autofocus` (boolean) - set focus to the player when the media starts playing
  
- - `autoplay` (boolean) - start to play automatically on initialization 
+ - `autoplay` (boolean) - start to play automatically on initialization and after opening `ESLToggleable` container with media. Won't be automatically play inside `ESLToggleable` instance and was previously stopped by the user.
  *(note: initialization doesn't happen until `disabled` attribute is removed from the element)*
  
  - `controls` (boolean) - show media player controls
  
  - `loop` (boolean) - play media in loop
  
- - `mute` (boolean) - mute media
+ - `muted` (boolean) - mute media
  
  - `playsinline` (boolean) - allow playing media inline (media player will not request special control over device)
 
@@ -88,7 +144,7 @@ using a single tag as well as work with external providers using simple native-l
 *(note: that feature doesn't work for Abstract Iframe provider, also doesn't work for HTMLAudio and HTMLVideo providers in case when Web-server when hosted resource doesn't support ['Accept-Ranges' HTTP response marker](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges))*
  - `focusable` (boolean) - marker that allows the video to be focused by keyboard navigation. By default, the video is focusable if `controls` are enabled.
 
-#### Deprecated attributes (going to be removed in the next major release):
+#### Deprecated attributes (going to be removed in 5.0.0):
  - `load-cls-accepted` (optional) - class to add when the media is loaded and accepted by the load condition. Use `load-condition-class` instead.
  - `load-cls-declined` (optional) - class to add when the media is loaded and rejected by the load condition. Use `load-condition-class` with inverted syntax (`!class`) instead.
  - `load-cls-target` (optional) - [ESLTraversingQuery](../esl-traversing-query/README.md) to define a target for `load-cls-accepted` and `load-cls-declined`
@@ -105,11 +161,16 @@ using a single tag as well as work with external providers using simple native-l
 ### Events: 
  - `esl:media:error` - (bubbles) fires when API is initialized with error
  - `esl:media:ready` - (bubbles) fires when API is ready
+ - `esl:media:before:play` - (bubbles, cancelable) fires before player provider requested to play  
+    Note: This event may be omitted if the provider starts the video automatically outside the ESLMedia cycle.  
+    For example, Chrome may stop videos that are out of the viewport automatically and attempt to restart them when they return to view.
+    To ensure consistent event triggering, use the esl-media play-in-viewport feature.
  - `esl:media:play` - (bubbles) fires when esl-media starts playing
  - `esl:media:paused` - (bubbles) fires when esl-media is paused
  - `esl:media:ended` - (bubbles) fires when esl-media is ended
  - `esl:media:detach` - (bubbles) fires after esl-media provider is detached (reinitialized / disconnected from the DOM)
- - `esl:media:managedpause` - (bubbles) fires when media was paused by esl-media group restriction manager
+ - `esl:media:managedpause` - (bubbles, cancelable) fires when media was paused by esl-media group restriction manager
+ - `esl:media:managedaction` - used to manage media actions such as releasing or suspending media instances within a specified scope
  
 ### Examples:
 ```html
@@ -117,7 +178,7 @@ using a single tag as well as work with external providers using simple native-l
     media-type="youtube"
     media-id="##MEDIAID##"
     title="Video Title"     
-    [ disabled ]    
-    [ group="mediaGroup" ]
+    disabled 
+    group="mediaGroup"
 ></esl-media>
 ```
diff --git a/src/modules/esl-media/control/esl-media-control-mixin.ts b/src/modules/esl-media/control/esl-media-control-mixin.ts
index bf16f2951..f1d3d6dda 100644
--- a/src/modules/esl-media/control/esl-media-control-mixin.ts
+++ b/src/modules/esl-media/control/esl-media-control-mixin.ts
@@ -14,7 +14,7 @@ export interface ESLMediaControlConfig {
 
 @ExportNs('MediaControlMixin')
 export class ESLMediaControlMixin extends ESLMixinElement {
-  public static override is = 'esl-media-contol';
+  public static override is = 'esl-media-control';
 
   public static readonly DEFAULT_CONFIG: ESLMediaControlConfig = {
     action: 'toggle'
diff --git a/src/modules/esl-media/core.ts b/src/modules/esl-media/core.ts
index bc227f6b1..fec636e4e 100644
--- a/src/modules/esl-media/core.ts
+++ b/src/modules/esl-media/core.ts
@@ -1,6 +1,7 @@
 export type {ESLMediaTagShape} from './core/esl-media.shape';
 
 export * from './core/esl-media';
+export * from './core/esl-media.events';
 export * from './core/esl-media-provider';
 export * from './core/esl-media-registry';
 export * from './control/esl-media-control-mixin';
diff --git a/src/modules/esl-media/core/esl-media-iobserver.ts b/src/modules/esl-media/core/esl-media-iobserver.ts
index f24de4ddc..bbd8d49f5 100644
--- a/src/modules/esl-media/core/esl-media-iobserver.ts
+++ b/src/modules/esl-media/core/esl-media-iobserver.ts
@@ -1,10 +1,5 @@
 import type {ESLMedia} from './esl-media';
 
-const RATIO_TO_PLAY = 0.5; // TODO: customizable, at least global
-const RATIO_TO_STOP = 0.20; // TODO: customizable, at least global
-
-const RATIO_TO_ACTIVATE = 0.05;
-
 let iObserver: IntersectionObserver;
 
 /** ESL Media Play-In-Viewport IntersectionObserver instance */
@@ -13,7 +8,7 @@ export function getIObserver(lazy: boolean = false): IntersectionObserver {
     iObserver = new IntersectionObserver(function (entries) {
       entries.forEach(handleViewport);
     }, {
-      threshold: [RATIO_TO_STOP, RATIO_TO_PLAY]
+      threshold: [0.05, 0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 0.9, 1]
     });
   }
   return iObserver;
@@ -21,17 +16,32 @@ export function getIObserver(lazy: boolean = false): IntersectionObserver {
 
 function handleViewport(entry: IntersectionObserverEntry): void {
   const video = entry.target as ESLMedia;
+  const root = entry.rootBounds || {
+    width: Number.POSITIVE_INFINITY,
+    height: Number.POSITIVE_INFINITY
+  };
+  const rect = entry.intersectionRect;
+  const ratio = Math.max(
+    (rect.width * rect.height) / (root.width * root.height),
+    entry.intersectionRatio
+  );
+
   // Removes `lazy` attribute when media is in the viewport with min ratio RATIO_TO_ACTIVATE
-  if (entry.isIntersecting && entry.intersectionRatio >= RATIO_TO_ACTIVATE && video.lazy === 'auto') {
+  if (entry.isIntersecting && ratio >= video.RATIO_TO_ACTIVATE && video.lazy === 'auto') {
     video.$$attr('lazy', false);
   }
 
-  // Videos that playing and out of min ratio RATIO_TO_STOP should be stopped
-  if (video.active && entry.intersectionRatio <= RATIO_TO_STOP) {
-    video.pause();
+  // Skip if video state management if feature is disabled
+  if (!video.playInViewport) return;
+
+  // Videos that playing and out of min ratio should be stopped
+  if (video.active && ratio <= video.RATIO_TO_STOP) {
+    video.pause(true);
   }
-  // Play should start only for inactive and background(muted) videos that are visible more than on RATIO_TO_PLAY
-  if (!video.active && video.autoplay && entry.intersectionRatio >= RATIO_TO_PLAY) {
-    video.play();
+  // Videos that not playing and in min ratio to start should be started
+  if (!video.active && ratio >= video.RATIO_TO_PLAY) {
+    // Disallow for non autoplay-able videos or videos that was controlled by user
+    if (!video.autoplay || video.isUserInitiated) return;
+    video.play(video.canActivate(), true);
   }
 }
diff --git a/src/modules/esl-media/core/esl-media-manager.ts b/src/modules/esl-media/core/esl-media-manager.ts
index a0766dc32..eda5e5cea 100644
--- a/src/modules/esl-media/core/esl-media-manager.ts
+++ b/src/modules/esl-media/core/esl-media-manager.ts
@@ -1,34 +1,124 @@
-import type {ESLMedia} from './esl-media';
+import {ESLEventUtils} from '../../esl-event-listener/core/api';
+import {isSafeContains} from '../../esl-utils/dom/traversing';
+import {isVisible} from '../../esl-utils/dom/visible';
+import {listen} from '../../esl-utils/decorators';
+import {ESLMediaHookEvent} from './esl-media.events';
 
-const managerMap = new Map<string, ESLMedia>();
+import type {ESLMedia} from './esl-media';
 
 /**
- * Group restriction manager for {@link ESLMedia}
- * Only one media in group can be played
- * Empty group is ignored
- * @author Alexey Stsefanovich (ala'n), Yuliya Adamskaya
+ * Manager for {@link ESLMedia}
+ * Checks whether media should play inside ESLToggleable container,
+ * stores all active instances and instances marked with `autoplay` attribute
+ * Restrict that only one media from the group can be played
+ *
+ * @author Anastasia Lesun
  */
-export class MediaGroupRestrictionManager {
-  /** @internal */
-  static get managerMap(): Map<string, ESLMedia> {
-    return managerMap;
-  }
-
-  /** Register instance play state in group */
-  public static registerPlay(instance: ESLMedia): void {
-    if (instance.group) {
-      const current = managerMap.get(instance.group);
-      managerMap.set(instance.group, instance);
-      if (!current || current === instance || !current.active) return;
-      if (current.$$fire(current.MANAGED_PAUSE_EVENT)) current.pause();
-    }
+export class ESLMediaManager {
+  /** All managed instances */
+  public instances: Set<ESLMedia> = new Set();
+
+  /** Active instances */
+  public get active(): ESLMedia[] {
+    return Array.from(this.instances).filter((player: ESLMedia) => player.active);
   }
 
-  /** Unregister instance */
-  public static unregister(instance: ESLMedia): void {
-    if (instance.group) {
-      const reg = managerMap.get(instance.group);
-      if (reg === instance) managerMap.delete(instance.group);
+  /** Instances with autoplay marker */
+  public get autoplayable(): ESLMedia[] {
+    return Array.from(this.instances).filter((player: ESLMedia) => player.autoplay);
+  }
+
+  public constructor() {
+    ESLEventUtils.subscribe(this);
+  }
+
+  /** Hook for {@link ESLMedia} initialization */
+  public _onInit(media: ESLMedia): void {
+    this.instances.add(media);
+  }
+
+  /** Hook for {@link ESLMedia} destroy */
+  public _onDestroy(media: ESLMedia): void {
+    this.instances.delete(media);
+  }
+
+  /** Hook for {@link ESLMedia} which is started to play */
+  public _onAfterPlay(media: ESLMedia): void {
+    this.active.forEach((player: ESLMedia) => {
+      if (player === media) return;
+      if (!media.group || player.group !== media.group) return;
+      const event = new ESLMediaHookEvent(player.MANAGED_PAUSE_EVENT, {
+        initiator: 'system',
+        relatedMedia: media
+      });
+      if (player.dispatchEvent(event)) player.pause(true);
+    });
+  }
+
+  /**
+   * Processes {@link ESLToggleable} show event and resumes all media inside.
+   * @see {@link ESLMediaManager#releaseAll}
+   */
+  @listen({event: 'esl:show', target: window})
+  public _onContainerShow(e: Event): void {
+    this.releaseAll(e.target as Element);
+  }
+
+  /**
+   * Processes {@link ESLToggleable} hide event and systemically suspend all media instances inside.
+   * @see {@link ESLMediaManager#suspendAll}
+   */
+  @listen({event: 'esl:hide', target: window})
+  protected _onContainerHide(e: Event): void {
+    this.suspendAll(e.target as Element);
+  }
+
+  /**
+   * Starts all system-stopped media with autoplay marker
+   * @param scope - the scope to search for media
+   * @param system - whether to start only system-stopped media
+   */
+  protected releaseAll(scope: Element = document.body, system = true): void {
+    this.autoplayable.forEach(($media: ESLMedia) => {
+      if (system && $media.isUserInitiated) return;
+      if (!$media.autoplay) return;
+      if (!isSafeContains(scope, $media)) return;
+      if (!isVisible($media, {visibility: true, viewport: $media.playInViewport})) return;
+      $media.play(false, true);
+    });
+  }
+
+  /**
+   * Pauses all active media that was not started manually
+   * @param scope - the scope to search for media
+   * @param system - whether to pause only system-active media
+   */
+  protected suspendAll(scope: Element = document.body, system = true): void {
+    // Pause all instances (to notify an inner player about the pause)
+    // Note: Chrome may try to play automatically paused video even if it in paused state
+    this.instances.forEach(($media: ESLMedia) => {
+      if (system && $media.isUserInitiated) return;
+      if (!isSafeContains(scope, $media)) return;
+      $media.pause(true);
+    });
+  }
+
+  /** Processes request to play/pause media */
+  @listen({event: 'esl:media:managedaction', target: window})
+  protected _onRequest(e: CustomEvent): void {
+    switch (e.detail.action) {
+      case 'release':
+        this.releaseAll(e.detail.scope, !e.detail.force);
+        break;
+      case 'suspend':
+        this.suspendAll(e.detail.scope, !e.detail.force);
+        break;
     }
   }
 }
+
+declare global {
+  export interface ESLLibrary {
+    ESLMediaManager: typeof ESLMediaManager;
+  }
+}
diff --git a/src/modules/esl-media/core/esl-media-provider.ts b/src/modules/esl-media/core/esl-media-provider.ts
index 64bec718a..5fb6cd7d7 100644
--- a/src/modules/esl-media/core/esl-media-provider.ts
+++ b/src/modules/esl-media/core/esl-media-provider.ts
@@ -1,3 +1,4 @@
+import {DelayedTask} from '../../esl-utils/async/delayed-task';
 import {ESLMediaProviderRegistry} from './esl-media-registry';
 
 import type {ESLMedia} from './esl-media';
@@ -52,14 +53,23 @@ export abstract class BaseProvider {
   protected component: ESLMedia;
   protected _el: HTMLElement;
   protected _ready: Promise<any>;
+  protected _cmdMng: DelayedTask = new DelayedTask();
+  protected _lastCmdType: string;
 
   public constructor(component: ESLMedia, config: MediaProviderConfig) {
     this.config = config;
     this.component = component;
+    // Other-vice browser will handle play-in-viewport it differently
+    if (this.component.playInViewport) this.config.autoplay = false;
+    // If autoplay is enabled - dispatch before play event to ensure it is allowed
+    if (this.config.autoplay) {
+      this.config.autoplay = this.component._onBeforePlay('initial');
+    }
+    this._lastCmdType = this.config.autoplay ? 'play' : 'pause';
   }
 
   /** Wraps _ready promise */
-  get ready(): Promise<any> {
+  public get ready(): Promise<any> {
     if (!this._ready) {
       const res = Promise.reject('Not Initialized');
       res.catch((e) => console.log('Rejected Media Operation: ', e));
@@ -137,21 +147,39 @@ export abstract class BaseProvider {
   }
 
   /** Executes play when api is ready */
-  public safePlay(): Promise<void> {
-    return this.ready.then(() => this.play());
+  public async safePlay(system = false): Promise<void> {
+    await this.ready;
+    this._cmdMng.put(() => {
+      if (!this.component._onBeforePlay(system ? 'system' : 'user')) return;
+      this._lastCmdType = 'play';
+      this.play();
+    }, 0);
   }
 
   /** Executes pause when api is ready */
-  public safePause(): Promise<void> {
-    return this.ready.then(() => this.pause());
+  public async safePause(): Promise<void> {
+    await this.ready;
+    this._cmdMng.put(() => {
+      this._lastCmdType = 'pause';
+      this.pause();
+    }, 0);
   }
 
   /**
    * Executes stop when api is ready
    * @returns Promise
    */
-  public safeStop(): Promise<void> {
-    return this.ready.then(() => this.stop());
+  public async safeStop(): Promise<void> {
+    await this.ready;
+    this._cmdMng.put(() => {
+      this._lastCmdType = 'pause';
+      this.stop();
+    }, 0);
+  }
+
+  /** @returns last requested command type */
+  public get lastCommand(): string {
+    return this._lastCmdType;
   }
 
   /**
diff --git a/src/modules/esl-media/core/esl-media-registry.ts b/src/modules/esl-media/core/esl-media-registry.ts
index 773661ffc..bfd373cd2 100644
--- a/src/modules/esl-media/core/esl-media-registry.ts
+++ b/src/modules/esl-media/core/esl-media-registry.ts
@@ -65,6 +65,11 @@ export class ESLMediaProviderRegistry extends SyntheticEventTarget {
   /** Create provider instance for passed configuration */
   private static _create(provider: ProviderType, media: ESLMedia, cfg = provider.parseUrl(media.mediaSrc)): BaseProvider {
     const config = Object.assign({}, cfg || {}, provider.parseConfig(media));
-    return new provider(media, config);
+    const instance = new provider(media, config);
+    if (instance) {
+      instance.bind();
+      console.debug('[ESL] Media provider created', instance);
+    }
+    return instance;
   }
 }
diff --git a/src/modules/esl-media/core/esl-media.events.ts b/src/modules/esl-media/core/esl-media.events.ts
new file mode 100644
index 000000000..5f02340aa
--- /dev/null
+++ b/src/modules/esl-media/core/esl-media.events.ts
@@ -0,0 +1,26 @@
+import type {ESLMedia} from './esl-media';
+
+export interface ESLMediaHookEventInit {
+  initiator: 'initial' | 'user' | 'system';
+  relatedMedia?: ESLMedia;
+}
+
+/**
+ * Event class for {@link ESLMedia} hooks
+ * (cancelable events dispatched during ESLMedia lifecycle)
+ */
+export class ESLMediaHookEvent extends Event implements ESLMediaHookEventInit {
+  public override readonly target: ESLMedia;
+
+  public readonly initiator: 'initial' | 'user' | 'system';
+  public readonly relatedMedia?: ESLMedia;
+
+  constructor(type: string, init: ESLMediaHookEventInit) {
+    super(type, {
+      bubbles: true,
+      cancelable: true,
+      composed: true
+    });
+    Object.assign(this, init);
+  }
+}
diff --git a/src/modules/esl-media/core/esl-media.ts b/src/modules/esl-media/core/esl-media.ts
index 8fd0f2f78..d920d2c99 100644
--- a/src/modules/esl-media/core/esl-media.ts
+++ b/src/modules/esl-media/core/esl-media.ts
@@ -1,9 +1,10 @@
 import {ESLBaseElement} from '../../esl-base-element/core';
 import {ExportNs} from '../../esl-utils/environment/export-ns';
-import {isElement} from '../../esl-utils/dom/api';
+import {isSafeContains} from '../../esl-utils/dom/traversing';
 import {CSSClassUtils} from '../../esl-utils/dom/class';
 import {SPACE, PAUSE} from '../../esl-utils/dom/keys';
-import {prop, attr, boolAttr, listen} from '../../esl-utils/decorators';
+import {isInViewport} from '../../esl-utils/dom/visible';
+import {prop, attr, boolAttr, listen, memoize} from '../../esl-utils/decorators';
 import {debounce} from '../../esl-utils/async';
 import {parseAspectRatio, parseBoolean} from '../../esl-utils/misc/format';
 
@@ -14,7 +15,8 @@ import {ESLTraversingQuery} from '../../esl-traversing-query/core';
 import {getIObserver} from './esl-media-iobserver';
 import {PlayerStates} from './esl-media-provider';
 import {ESLMediaProviderRegistry} from './esl-media-registry';
-import {MediaGroupRestrictionManager} from './esl-media-manager';
+import {ESLMediaManager} from './esl-media-manager';
+import {ESLMediaHookEvent} from './esl-media.events';
 
 import type {BaseProvider} from './esl-media-provider';
 import type {ESLMediaRegistryEvent} from './esl-media-registry.event';
@@ -22,8 +24,8 @@ import type {ESLMediaRegistryEvent} from './esl-media-registry.event';
 export type ESLMediaFillMode = 'cover' | 'inscribe' | '';
 
 export type ESLMediaLazyMode = 'auto' | 'manual' | 'none';
+
 const isLazyAttr = (v: string): v is ESLMediaLazyMode => ['auto', 'manual', 'none'].includes(v);
-const parseLazyAttr = (v: string): ESLMediaLazyMode => isLazyAttr(v) ? v : 'auto';
 
 /**
  * ESLMedia - custom element, that provides an ability to add and configure media (video / audio)
@@ -49,10 +51,25 @@ export class ESLMedia extends ESLBaseElement {
     'start-time'
   ];
 
+  /** Singleton instance of {@link ESLMediaManager} */
+  @memoize()
+  public static get manager(): ESLMediaManager {
+    return new ESLMediaManager();
+  }
+
+  /** A minimum ratio to init media (in case of lazy=auto) */
+  @prop(0.05) public RATIO_TO_ACTIVATE: number;
+  /** A minimum ratio to stop media (in case of play in viewport option) */
+  @prop(0.2) public RATIO_TO_STOP: number;
+  /** A minimum ratio to play media (in case of play in viewport option) */
+  @prop(0.33) public RATIO_TO_PLAY: number;
+
   /** Event to dispatch on ready state */
   @prop('esl:media:ready') public READY_EVENT: string;
   /** Event to dispatch on error state */
   @prop('esl:media:error') public ERROR_EVENT: string;
+  /** Event to dispatch before player provider requested to play (cancelable) */
+  @prop('esl:media:before:play') public BEFORE_PLAY_EVENT: string;
   /** Event to dispatch when player plays */
   @prop('esl:media:play') public PLAY_EVENT: string;
   /** Event to dispatch when player paused */
@@ -61,7 +78,7 @@ export class ESLMedia extends ESLBaseElement {
   @prop('esl:media:ended') public ENDED_EVENT: string;
   /** Event to dispatch when player detached */
   @prop('esl:media:detached') public DETACHED_EVENT: string;
-  /** Event to dispatch when player paused by another instance in group */
+  /** Event to dispatch when player paused by another instance in group (cancelable) */
   @prop('esl:media:managedpause') public MANAGED_PAUSE_EVENT: string;
 
   /** Media resource identifier */
@@ -78,9 +95,13 @@ export class ESLMedia extends ESLBaseElement {
   /** Strict aspect ratio definition */
   @attr() public aspectRatio: string;
   /** Allows lazy load resource */
-  @attr({parser: parseLazyAttr, defaultValue: 'none'}) public lazy: ESLMediaLazyMode;
+  @attr({
+    parser: (value: string) => isLazyAttr(value) ? value : 'auto',
+    defaultValue: 'none'
+  }) public lazy: ESLMediaLazyMode;
   /** Autoplay resource marker */
   @boolAttr() public autoplay: boolean;
+
   /** Autofocus on play marker */
   @boolAttr() public override autofocus: boolean;
   /** Mute resource marker */
@@ -96,7 +117,10 @@ export class ESLMedia extends ESLBaseElement {
   /** Allows to start viewing a resource from a specific time offset. */
   @attr({parser: parseInt}) public startTime: number;
   /** Allows player to accept focus */
-  @attr({parser: parseBoolean, defaultValue: ($this: ESLMedia) => $this.controls}) public focusable: boolean;
+  @attr({
+    parser: parseBoolean,
+    defaultValue: ($this: ESLMedia) => $this.controls
+  }) public focusable: boolean;
 
 
   /** Preload resource */
@@ -125,9 +149,12 @@ export class ESLMedia extends ESLBaseElement {
   /** @readonly Width is greater than height state marker */
   @boolAttr({readonly: true}) public wide: boolean;
 
-  private _provider: BaseProvider | null;
-
-  private deferredReinitialize = debounce(() => this.reinitInstance());
+  /** Marker if the last action (play/pause/stop) was initiated by the user */
+  protected _isManualAction: boolean;
+  /** Applied provider instance */
+  protected _provider: BaseProvider | null;
+  /** Deferred reinitialize handler, to prevent multiple reinitialization calls in bound of the macro-task */
+  protected deferredReinitialize = debounce(() => this.reinitInstance());
 
   /**
    * Map object with possible Player States, values:
@@ -137,12 +164,19 @@ export class ESLMedia extends ESLBaseElement {
     return PlayerStates;
   }
 
+  /** Returns true if the provider with given name is supported */
   static supports(name: string): boolean {
     return ESLMediaProviderRegistry.instance.has(name);
   }
 
+  /** @readonly {@link ESLMediaManager} used for current instance */
+  protected get manager(): ESLMediaManager {
+    return (this.constructor as typeof ESLMedia).manager;
+  }
+
   protected override connectedCallback(): void {
     super.connectedCallback();
+    this.manager._onInit(this);
     if (!this.hasAttribute('role')) {
       this.setAttribute('role', 'application');
     }
@@ -152,6 +186,7 @@ export class ESLMedia extends ESLBaseElement {
   }
 
   protected override disconnectedCallback(): void {
+    this.manager._onDestroy(this);
     super.disconnectedCallback();
     this.detachViewportConstraint();
     this._provider && this._provider.unbind();
@@ -164,8 +199,6 @@ export class ESLMedia extends ESLBaseElement {
       case 'media-src':
       case 'media-type':
       case 'start-time':
-        this.deferredReinitialize();
-        break;
       case 'lazy':
         this.reattachViewportConstraint();
         this.deferredReinitialize();
@@ -195,18 +228,13 @@ export class ESLMedia extends ESLBaseElement {
   }
 
   private reinitInstance(): void {
-    console.debug('[ESL] Media reinitialize ', this);
-    this._provider && this._provider.unbind();
+    this._provider?.unbind();
     this._provider = null;
+    this._isManualAction = false;
 
     if (this.canActivate()) {
       this._provider = ESLMediaProviderRegistry.instance.createFor(this);
-      if (this._provider) {
-        this._provider.bind();
-        console.debug('[ESL] Media provider bound', this._provider);
-      } else {
-        this._onError();
-      }
+      if (!this._provider) this._onError();
     }
 
     this.updateContainerMarkers();
@@ -219,31 +247,34 @@ export class ESLMedia extends ESLBaseElement {
 
   /** Seek to given position of media */
   public seekTo(pos: number): Promise<void> | null {
-    return this._provider && this._provider.safeSeekTo(pos);
+    return this._provider?.safeSeekTo(pos) || null;
   }
 
   /**
    * Start playing media
    * @param allowActivate - allows to remove manual lazy loading restrictions
+   * @param system - marks that the action was initiated by the system
    */
-  public play(allowActivate: boolean = false): Promise<void> | null {
+  public play(allowActivate: boolean = false, system = false): Promise<void> | null {
     if (!this.ready && allowActivate) {
       this.lazy = 'none';
       this.deferredReinitialize.cancel();
       this.reinitInstance();
     }
-    if (!this.canActivate()) return null;
-    return this._provider && this._provider.safePlay();
+    this._isManualAction = !system;
+    return this._provider?.safePlay(system) || null;
   }
 
   /** Pause playing media */
-  public pause(): Promise<void> | null {
-    return this._provider && this._provider.safePause();
+  public pause(system = false): Promise<void> | null {
+    this._isManualAction = !system;
+    return this._provider?.safePause() || null;
   }
 
   /** Stop playing media */
-  public stop(): Promise<void> | null {
-    return this._provider && this._provider.safeStop();
+  public stop(system = false): Promise<void> | null {
+    this._isManualAction = !system;
+    return this._provider?.safeStop() || null;
   }
 
   /**
@@ -257,52 +288,69 @@ export class ESLMedia extends ESLBaseElement {
 
   /** Focus inner player **/
   public focusPlayer(): void {
-    this._provider && this._provider.focus();
+    this._provider?.focus();
+  }
+
+  /** Detects if the user manipulate trough native controls */
+  protected detectUserInteraction(cmd: string): void {
+    if (!this.controls || this._isManualAction) return;
+    if (this._provider?.lastCommand === cmd) return;
+    // User cannot manipulate the player outside the viewport
+    const tolerance = this.RATIO_TO_ACTIVATE * this.clientWidth * this.clientHeight;
+    if (!isInViewport(this, tolerance)) return;
+    console.debug('[ESL]: User %s interaction detected', cmd);
+    this._isManualAction = true;
   }
 
   // media live-cycle handlers
   public _onReady(): void {
-    this.toggleAttribute('ready', true);
-    this.toggleAttribute('error', false);
+    this.$$attr('ready', true);
+    this.$$attr('error', false);
     this.updateReadyClass();
     this.$$fire(this.READY_EVENT);
     this._onResize();
   }
 
   public _onError(detail?: any, setReadyState = true): void {
-    this.toggleAttribute('ready', true);
-    this.toggleAttribute('error', true);
+    this.$$attr('ready', true);
+    this.$$attr('error', true);
     this.$$fire(this.ERROR_EVENT, {detail});
     setReadyState && this.$$fire(this.READY_EVENT);
   }
 
   public _onDetach(): void {
-    this.removeAttribute('active');
-    this.removeAttribute('ready');
-    this.removeAttribute('played');
+    this.$$attr('active', false);
+    this.$$attr('ready', false);
+    this.$$attr('played', false);
     this.updateReadyClass();
     this.$$fire(this.DETACHED_EVENT);
   }
 
+  public _onBeforePlay(initiator: 'initial' | 'user' | 'system'): boolean {
+    const event = new ESLMediaHookEvent(this.BEFORE_PLAY_EVENT, {initiator});
+    return this.dispatchEvent(event);
+  }
+
   public _onPlay(): void {
+    this.detectUserInteraction('play');
     if (this.autofocus) this.focus();
-    this.toggleAttribute('active', true);
-    this.toggleAttribute('played', true);
+    this.$$attr('active', true);
+    this.$$attr('played', true);
     this.$$fire(this.PLAY_EVENT);
-    MediaGroupRestrictionManager.registerPlay(this);
+    this.manager._onAfterPlay(this);
     this._onResize();
   }
 
   public _onPaused(): void {
-    this.removeAttribute('active');
+    this.detectUserInteraction('pause');
+    this.$$attr('active', false);
     this.$$fire(this.PAUSED_EVENT);
-    MediaGroupRestrictionManager.unregister(this);
   }
 
   public _onEnded(): void {
-    this.removeAttribute('active');
+    this.detectUserInteraction('pause');
+    this.$$attr('active', false);
     this.$$fire(this.ENDED_EVENT);
-    MediaGroupRestrictionManager.unregister(this);
   }
 
   @listen({
@@ -322,8 +370,8 @@ export class ESLMedia extends ESLBaseElement {
     target: window
   })
   protected _onRefresh(e: Event): void {
-    const {target} = e;
-    if (isElement(target) && target.contains(this)) this._onResize();
+    if (!isSafeContains(e.target as Node, this)) return;
+    this._onResize();
   }
 
   @listen({
@@ -345,11 +393,10 @@ export class ESLMedia extends ESLBaseElement {
   @listen('keydown')
   protected _onKeydown(e: KeyboardEvent): void {
     if (e.target !== this) return;
-    if ([SPACE, PAUSE].includes(e.key)) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.toggle();
-    }
+    if (![SPACE, PAUSE].includes(e.key)) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggle();
   }
 
   /** Update ready class state */
@@ -360,7 +407,15 @@ export class ESLMedia extends ESLBaseElement {
 
   /** Applied provider */
   public get providerType(): string {
-    return this._provider ? this._provider.name : '';
+    return this._provider?.name || '';
+  }
+
+  /**
+   * Marker if the last action (play/pause/stop) was initiated by the user
+   * (direct method call or by embed player controls)
+   */
+  public get isUserInitiated(): boolean {
+    return this._isManualAction;
   }
 
   /** Current player state, see {@link ESLMedia.PLAYER_STATES} values */
@@ -370,17 +425,17 @@ export class ESLMedia extends ESLBaseElement {
 
   /** Duration of the media resource */
   public get duration(): number {
-    return this._provider ? this._provider.duration : 0;
+    return this._provider?.duration || 0;
   }
 
   /** Current time of media resource */
   public get currentTime(): number {
-    return this._provider ? this._provider.currentTime : 0;
+    return this._provider?.currentTime || 0;
   }
 
   /** Set current time of media resource */
   public set currentTime(time: number) {
-    (this._provider) && this._provider.safeSeekTo(time);
+    this._provider?.safeSeekTo(time);
   }
 
   /** ESLMediaQuery to limit ESLMedia loading */
@@ -396,7 +451,7 @@ export class ESLMedia extends ESLBaseElement {
   /** Used resource aspect ratio forced by attribute or returned by provider */
   public get actualAspectRatio(): number {
     if (this.aspectRatio && this.aspectRatio !== 'auto') return parseAspectRatio(this.aspectRatio);
-    return this._provider ? this._provider.defaultAspectRatio : 0;
+    return this._provider?.defaultAspectRatio || 0;
   }
 
   protected reattachViewportConstraint(): void {
@@ -405,8 +460,7 @@ export class ESLMedia extends ESLBaseElement {
     getIObserver().observe(this);
   }
   protected detachViewportConstraint(): void {
-    const observer = getIObserver(true);
-    observer && observer.unobserve(this);
+    getIObserver(true)?.unobserve(this);
   }
 }
 
diff --git a/src/modules/esl-utils/dom/visible.ts b/src/modules/esl-utils/dom/visible.ts
index 6f6d49004..51ee85d85 100644
--- a/src/modules/esl-utils/dom/visible.ts
+++ b/src/modules/esl-utils/dom/visible.ts
@@ -34,11 +34,12 @@ export function isVisible(el: Element, options: VisibilityOptions = {visibility:
 /**
  * Checks if the specified element is inside the viewport
  * @param el - element to be checked
+ * @param tolerance - the minimum area of intersection to consider the element visible
  */
-export function isInViewport(el: Element): boolean {
+export function isInViewport(el: Element, tolerance = 0): boolean {
   const wndIntersection = Rect.from(el).intersect(getViewportRect());
-  if (wndIntersection.area === 0) return false;
+  if (wndIntersection.area <= tolerance) return false;
   return !getListScrollParents(el).some(
-    (parent: Element) => Rect.from(parent).intersect(wndIntersection).area === 0
+    (parent: Element) => Rect.from(parent).intersect(wndIntersection).area <= tolerance
   );
 }


Test patch:
diff --git a/src/modules/esl-media/test/base-provider.livecycle.test.ts b/src/modules/esl-media/test/base-provider.livecycle.test.ts
new file mode 100644
index 000000000..b89de2d12
--- /dev/null
+++ b/src/modules/esl-media/test/base-provider.livecycle.test.ts
@@ -0,0 +1,67 @@
+import {ESLMedia} from '../core/esl-media';
+import {promisifyTimeout} from '../../esl-utils/async/promise';
+import {BaseProviderMock} from './mocks/base-provider.mock';
+
+describe('ESLMedia: BaseProvider lifecycle', () => {
+  jest.spyOn(console, 'debug').mockImplementation(() => {});
+
+  ESLMedia.register();
+  BaseProviderMock.register();
+
+  let instance: ESLMedia;
+  beforeAll(() => {
+    instance = new ESLMedia();
+    instance.mediaSrc = 'mock';
+    instance.mediaType = 'mock';
+  });
+
+  test('Initial state is UNINITIALIZED', () => {
+    expect(instance.state).toBe(ESLMedia.PLAYER_STATES.UNINITIALIZED);
+  });
+
+  describe('ESLMedia: initialized', () => {
+    beforeAll(async () => {
+      document.body.appendChild(instance);
+      await promisifyTimeout(10);
+    });
+
+    test('After bind state is UNSTARTED', async () => {
+      expect(instance.state).toBe(ESLMedia.PLAYER_STATES.UNSTARTED);
+    });
+
+    test('After play state is PLAYING', async () => {
+      instance.play();
+      await promisifyTimeout(10);
+      expect(instance.state).toBe(ESLMedia.PLAYER_STATES.PLAYING);
+    });
+
+    test('After pause state is PAUSED', async () => {
+      instance.pause();
+      await promisifyTimeout(10);
+      expect(instance.state).toBe(ESLMedia.PLAYER_STATES.PAUSED);
+    });
+
+    test('After resume state is PLAYING', async () => {
+      instance.play();
+      await promisifyTimeout(10);
+      expect(instance.state).toBe(ESLMedia.PLAYER_STATES.PLAYING);
+    });
+
+    test('After stop state is ENDED', async () => {
+      instance.stop();
+      await promisifyTimeout(10);
+      expect(instance.state).toBe(ESLMedia.PLAYER_STATES.ENDED);
+    });
+
+    test('Banch of play/pause/stop commands stacked to the last one', async () => {
+      const playSpy = jest.spyOn(BaseProviderMock.prototype as any, 'play');
+      instance.play();
+      instance.pause();
+      instance.stop();
+      instance.play();
+      await promisifyTimeout(10);
+      expect(instance.state).toBe(ESLMedia.PLAYER_STATES.PLAYING);
+      expect(playSpy).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/src/modules/esl-media/test/base-provider.mock.ts b/src/modules/esl-media/test/base-provider.mock.ts
deleted file mode 100644
index 07f84c4b0..000000000
--- a/src/modules/esl-media/test/base-provider.mock.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {BaseProvider, PlayerStates} from '../core/esl-media-provider';
-
-export class BaseProviderMock extends BaseProvider {
-  public bind(): void {}
-
-  public get duration(): number {
-    return 0;
-  }
-
-  public get currentTime(): number {
-    return 0;
-  }
-
-  public get defaultAspectRatio(): number {
-    return 0;
-  }
-
-  protected pause(): void | Promise<any> {}
-
-  protected play(): void | Promise<any> {}
-
-  protected stop(): void | Promise<any> {}
-
-  protected seekTo(pos?: number): void | Promise<any> {}
-
-  public get state(): PlayerStates {
-    return PlayerStates.UNINITIALIZED;
-  }
-}
diff --git a/src/modules/esl-media/test/base-provider.test.ts b/src/modules/esl-media/test/base-provider.test.ts
index 3f0249949..06fb888cf 100644
--- a/src/modules/esl-media/test/base-provider.test.ts
+++ b/src/modules/esl-media/test/base-provider.test.ts
@@ -1,6 +1,6 @@
 import {BaseProvider} from '../core/esl-media-provider';
 import {ESLMediaProviderRegistry} from '../core/esl-media-registry';
-import {BaseProviderMock} from './base-provider.mock';
+import {BaseProviderMock} from './mocks/base-provider.mock';
 
 import type {ProviderType} from '../core/esl-media-provider';
 
@@ -16,7 +16,10 @@ describe('ESLMedia: BaseProvider tests', () => {
   });
 
   test('Provider should have correct name', () => {
-    expect(() => BaseProviderMock.register()).toThrowError();
+    class EmptyProvider extends BaseProviderMock {
+      static override readonly providerName = '';
+    }
+    expect(() => EmptyProvider.register()).toThrowError();
   });
 
   test('Test provider registered', () => {
diff --git a/src/modules/esl-media/test/esl-media.condition.test.ts b/src/modules/esl-media/test/esl-media.condition.test.ts
index f5d4c0dec..008557398 100644
--- a/src/modules/esl-media/test/esl-media.condition.test.ts
+++ b/src/modules/esl-media/test/esl-media.condition.test.ts
@@ -6,6 +6,8 @@ import {IntersectionObserverMock} from '../../esl-utils/test/intersectionObserve
 import {getMatchMediaMock} from '../../esl-utils/test/matchMedia.mock';
 
 describe('esl-media: lazy loading unit tests', () => {
+  jest.spyOn(console, 'debug').mockImplementation(() => {});
+
   beforeAll(() => {
     IntersectionObserverMock.mock();
     ESLMedia.register();
diff --git a/src/modules/esl-media/test/media-manager.test.ts b/src/modules/esl-media/test/media-manager.test.ts
index cade4a50b..6ca898380 100644
--- a/src/modules/esl-media/test/media-manager.test.ts
+++ b/src/modules/esl-media/test/media-manager.test.ts
@@ -1,42 +1,70 @@
-import '../../../polyfills/es5-target-shim';
 import {ESLMedia} from '../core/esl-media';
-import {MediaGroupRestrictionManager} from '../core/esl-media-manager';
+import {promisifyTimeout} from '../../esl-utils/async/promise';
+import {BaseProviderMock} from './mocks/base-provider.mock';
+
+describe('[ESLMedia]: ESLMediaManager tests', () => {
+  jest.spyOn(console, 'debug').mockImplementation(() => {});
 
-describe('ESLMedia: MediaGroupRestrictionManager tests', () => {
   ESLMedia.register();
+  BaseProviderMock.register();
 
-  test('registerPlay / unregister', () => {
-    const instance = new ESLMedia();
-    instance.group = 'test';
-    MediaGroupRestrictionManager.registerPlay(instance);
-    expect(MediaGroupRestrictionManager.managerMap.size).toBeGreaterThan(0);
-    MediaGroupRestrictionManager.unregister(instance);
-    expect(MediaGroupRestrictionManager.managerMap.size).toBe(0);
-  });
+  describe('ESLMediaManager: group behaviour', () => {
+    const isActive = (i: ESLMedia) => i.active;
+    const instances: ESLMedia[] = [];
 
-  test('registerPlay manged pause: same group', () => {
-    const instance1 = new ESLMedia();
-    const instance2 = new ESLMedia();
-    instance1.group = instance2.group = 'test';
-    Object.defineProperty(instance1, 'active', {value: true});
-    const pauseSpy = jest.spyOn(instance1, 'pause');
-    MediaGroupRestrictionManager.registerPlay(instance1);
-    MediaGroupRestrictionManager.registerPlay(instance1);
-    expect(pauseSpy).not.toBeCalled();
-    MediaGroupRestrictionManager.registerPlay(instance2);
-    expect(pauseSpy).toBeCalled();
-  });
+    beforeAll(async () => {
+      for (let i = 0; i < 3; i++) {
+        const instance = new ESLMedia();
+        instance.mediaSrc = 'mock';
+        instance.mediaType = 'mock';
+        instances.push(instance);
+      }
+      await ESLMedia.registered;
+    });
+    beforeEach(async () => {
+      document.body.append(...instances);
+      await promisifyTimeout(10);
+    });
+    afterEach(() => {
+      instances.forEach((i) => i.remove());
+    });
+
+    async function runPlayTestSequence(expectMatrix: boolean[][]): Promise<void> {
+      expect(instances.length).toBe(expectMatrix.length); // Ensure test data is correct
+      for (let i = 0; i < instances.length; i++) {
+        await instances[i].play();
+        await promisifyTimeout(50);
+        expect(instances.map(isActive)).toEqual(expectMatrix[i]);
+      }
+    }
+
+    test('ESLMediaManger: only one active instance allowed in the group', async () => {
+      instances.forEach((i) => i.group = 'test');
+      await runPlayTestSequence([
+        [true, false, false],
+        [false, true, false],
+        [false, false, true]
+      ]);
+    });
+
+    test('ESLMediaManger: instances of different groups can be active simultaneously', async () => {
+      instances.forEach((i, idx) => i.group = `group${idx}`);
+      await runPlayTestSequence([
+        [true, false, false],
+        [true, true, false],
+        [true, true, true]
+      ]);
+    });
 
-  test('registerPlay manged pause: different groups', () => {
-    const instance1 = new ESLMedia();
-    const instance2 = new ESLMedia();
-    instance1.group = 'test1';
-    instance2.group = 'test2';
-    const pauseSpy = jest.spyOn(instance1, 'pause');
-    MediaGroupRestrictionManager.registerPlay(instance1);
-    MediaGroupRestrictionManager.registerPlay(instance1);
-    expect(pauseSpy).not.toBeCalled();
-    MediaGroupRestrictionManager.registerPlay(instance2);
-    expect(pauseSpy).not.toBeCalled();
+    test('ESLMediaManager: instances without group does not affect any other instance', async () => {
+      instances[0].group = 'test';
+      instances[1].group = '';
+      instances[2].group = 'test';
+      await runPlayTestSequence([
+        [true, false, false],
+        [true, true, false],
+        [false, true, true]
+      ]);
+    });
   });
 });
diff --git a/src/modules/esl-media/test/mocks/base-provider.mock.ts b/src/modules/esl-media/test/mocks/base-provider.mock.ts
new file mode 100644
index 000000000..d362a97d1
--- /dev/null
+++ b/src/modules/esl-media/test/mocks/base-provider.mock.ts
@@ -0,0 +1,52 @@
+import {BaseProvider, PlayerStates} from '../../core/esl-media-provider';
+
+export class BaseProviderMock extends BaseProvider {
+  public static override readonly providerName: string = 'mock';
+
+  private _state = PlayerStates.UNINITIALIZED;
+
+  public bind(): void {
+    this._state = PlayerStates.UNSTARTED;
+    this._ready = Promise.resolve();
+    this._ready.then(() => this.component._onReady());
+  }
+
+  public override unbind(): void {
+    this._state = PlayerStates.UNINITIALIZED;
+    this.component._onDetach();
+    this._ready = null as any;
+  }
+
+  public get duration(): number {
+    return 100;
+  }
+
+  public get currentTime(): number {
+    return 0;
+  }
+
+  public get defaultAspectRatio(): number {
+    return 0;
+  }
+
+  protected pause(): void | Promise<any> {
+    this._state = PlayerStates.PAUSED;
+    this.component._onPaused();
+  }
+
+  protected play(): void | Promise<any> {
+    this._state = PlayerStates.PLAYING;
+    this.component._onPlay();
+  }
+
+  protected stop(): void | Promise<any> {
+    this._state = PlayerStates.ENDED;
+    this.component._onEnded();
+  }
+
+  protected seekTo(pos?: number): void | Promise<any> {}
+
+  public get state(): PlayerStates {
+    return this._state;
+  }
+}
diff --git a/src/modules/esl-utils/test/intersectionObserver.mock.ts b/src/modules/esl-utils/test/intersectionObserver.mock.ts
index 779d9126d..e00a26f2f 100644
--- a/src/modules/esl-utils/test/intersectionObserver.mock.ts
+++ b/src/modules/esl-utils/test/intersectionObserver.mock.ts
@@ -57,12 +57,13 @@ export class IntersectionObserverMock implements IntersectionObserver {
     target: Element,
     init: Partial<IntersectionObserverEntry>
   ): IntersectionObserverEntry {
+    const intersectionRatio = init.intersectionRatio || 0;
     return {
       // Defaults
       target,
-      intersectionRect: new RectMock(),
-      rootBounds: new RectMock(),
-      boundingClientRect: new RectMock(),
+      intersectionRect: new RectMock(0, 0, 2000, 2000 * intersectionRatio),
+      rootBounds: new RectMock(0, 0, 2000, 2000),
+      boundingClientRect: new RectMock(0, 0, 2000, 2000),
       isIntersecting: false,
       intersectionRatio: 0,
       time: Date.now(),