@outbook/webcomponents-player 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +126 -0
  3. package/_i18n/af.json +12 -0
  4. package/_i18n/ar.json +12 -0
  5. package/_i18n/bg.json +12 -0
  6. package/_i18n/bn.json +12 -0
  7. package/_i18n/ca.json +12 -0
  8. package/_i18n/cs.json +12 -0
  9. package/_i18n/da.json +12 -0
  10. package/_i18n/de.json +12 -0
  11. package/_i18n/el.json +12 -0
  12. package/_i18n/en.json +12 -0
  13. package/_i18n/es.json +12 -0
  14. package/_i18n/eu.json +12 -0
  15. package/_i18n/fa.json +12 -0
  16. package/_i18n/fi.json +12 -0
  17. package/_i18n/fr.json +12 -0
  18. package/_i18n/gl.json +12 -0
  19. package/_i18n/ha.json +12 -0
  20. package/_i18n/hi.json +12 -0
  21. package/_i18n/hu.json +12 -0
  22. package/_i18n/i18n.js +95 -0
  23. package/_i18n/id.json +12 -0
  24. package/_i18n/it.json +12 -0
  25. package/_i18n/ja.json +12 -0
  26. package/_i18n/km.json +12 -0
  27. package/_i18n/ko.json +12 -0
  28. package/_i18n/lo.json +12 -0
  29. package/_i18n/ms.json +12 -0
  30. package/_i18n/nl.json +12 -0
  31. package/_i18n/no.json +12 -0
  32. package/_i18n/pl.json +12 -0
  33. package/_i18n/pt.json +12 -0
  34. package/_i18n/ro.json +12 -0
  35. package/_i18n/ru.json +12 -0
  36. package/_i18n/sk.json +12 -0
  37. package/_i18n/sv.json +12 -0
  38. package/_i18n/sw.json +12 -0
  39. package/_i18n/th.json +12 -0
  40. package/_i18n/tl.json +12 -0
  41. package/_i18n/tr.json +12 -0
  42. package/_i18n/uk.json +12 -0
  43. package/_i18n/vi.json +12 -0
  44. package/_i18n/zh.json +12 -0
  45. package/_i18n/zu.json +12 -0
  46. package/_lib/background.js +28 -0
  47. package/_lib/controls/index.js +91 -0
  48. package/_lib/cover-fallback/index.js +48 -0
  49. package/_lib/get-cover.js +4 -0
  50. package/_lib/hooks.js +23 -0
  51. package/_lib/order-by-luminance.js +9 -0
  52. package/_lib/playlist/index.js +105 -0
  53. package/_lib/playlist/modules/item-duration.js +17 -0
  54. package/_lib/playlist/modules/show-cover.js +14 -0
  55. package/_lib/rgb-to-hex.js +13 -0
  56. package/_lib/setup-media-session.js +29 -0
  57. package/_lib/timeline/_lib/handle-timeline.js +42 -0
  58. package/_lib/timeline/index.js +121 -0
  59. package/_lib/track-info/index.js +36 -0
  60. package/_lib/track-text/index.js +20 -0
  61. package/_style/_lib/_variables.scss +5 -0
  62. package/_style/_modules/_controls.scss +50 -0
  63. package/_style/_modules/_cover-fallback.scss +52 -0
  64. package/_style/_modules/_playlist.scss +119 -0
  65. package/_style/_modules/_timeline.scss +125 -0
  66. package/_style/_modules/_track-info.scss +54 -0
  67. package/_style/player.style.js +4 -0
  68. package/package.json +48 -0
  69. package/player.js +324 -0
@@ -0,0 +1,105 @@
1
+ import { virtual } from 'haunted';
2
+ import { html, nothing } from 'lit';
3
+ import { isKeyEnterOrKeySpace, isEventClick } from 'a11y-key-conjurer';
4
+ import { classMap } from 'lit/directives/class-map.js';
5
+ import { TrackText } from '../track-text/index.js';
6
+ import { TypeIcon } from '@outbook/webcomponents-type-icon/shadow';
7
+ import { play_arrow, pause } from '@outbook/icons';
8
+ import { BadgeFileExtension } from '@outbook/webcomponents-badge-file-extension';
9
+ import { ItemDuration } from './modules/item-duration.js';
10
+ import { ShowCover } from './modules/show-cover.js';
11
+
12
+ export const Playlist = virtual(
13
+ ({
14
+ playlist: _playlist,
15
+ list = {},
16
+ indexSelected,
17
+ isPlaying,
18
+ handleAudio,
19
+ listId
20
+ }) => {
21
+ const {
22
+ showCovers = true,
23
+ showTrackNumber = true,
24
+ showIconIsPlaying = true,
25
+ showFileExtension = true
26
+ } = list;
27
+ function handleButton(index) {
28
+ return ev => {
29
+ if (isEventClick(ev) || isKeyEnterOrKeySpace(ev)) {
30
+ handleAudio.setTrack({ index, fromControls: false });
31
+ }
32
+ };
33
+ }
34
+ return html`
35
+ <div class="myth-playlist" id="${listId}">
36
+ <ul class="myth-playlist__items">
37
+ ${_playlist.map((item, index) => {
38
+ const isPlayingItem = indexSelected === index;
39
+ const itemClasses = classMap({
40
+ 'myth-playlist__item': true,
41
+ 'is-playing': isPlayingItem
42
+ });
43
+ return html`
44
+ <li class="${itemClasses}" data-playlist-item="${index}">
45
+ <div
46
+ class="myth-playlist__item-button"
47
+ tabindex="0"
48
+ role="button"
49
+ @click="${handleButton(index)}"
50
+ @keydown="${handleButton(index)}"
51
+ >
52
+ ${showCovers ? ShowCover({ cover: item.cover }) : nothing}
53
+ ${showIconIsPlaying && isPlayingItem
54
+ ? html`
55
+ <div
56
+ class="myth-playlist__item-block myth-playlist__item-playing-icon"
57
+ >
58
+ ${TypeIcon({
59
+ icon: isPlaying ? 'play_arrow' : 'pause',
60
+ icons: { play_arrow, pause },
61
+ extraClasses:
62
+ 'myth-playlist__item-playing-icon-inner'
63
+ })}
64
+ </div>
65
+ `
66
+ : nothing}
67
+ ${showTrackNumber
68
+ ? html`
69
+ <div
70
+ class="myth-playlist__item-block myth-item-text size-small"
71
+ >
72
+ ${item.trackNumber || index + 1}
73
+ </div>
74
+ `
75
+ : nothing}
76
+ <div class="myth-playlist__item-block">
77
+ ${TrackText({
78
+ title: item.title,
79
+ artist: item.artist,
80
+ album: item.album
81
+ })}
82
+ </div>
83
+ ${item.duration || showFileExtension
84
+ ? html`
85
+ <div class="myth-playlist__item-group is-last-right">
86
+ ${ItemDuration({ duration: item.duration })}
87
+ ${showFileExtension
88
+ ? html`
89
+ <div class="myth-playlist__item-block">
90
+ ${BadgeFileExtension({ text: item.format })}
91
+ </div>
92
+ `
93
+ : nothing}
94
+ </div>
95
+ `
96
+ : nothing}
97
+ </div>
98
+ </li>
99
+ `;
100
+ })}
101
+ </ul>
102
+ </div>
103
+ `;
104
+ }
105
+ );
@@ -0,0 +1,17 @@
1
+ import { virtual } from 'haunted';
2
+ import { html } from 'lit';
3
+ import {
4
+ readable as formatTime,
5
+ iso as formatTimeDuration
6
+ } from 'mystic-format-time';
7
+
8
+ export const ItemDuration = virtual(({ duration }) => {
9
+ return html`
10
+ <time
11
+ class="myth-playlist__item-block myth-item-text size-small"
12
+ datetime="${formatTimeDuration(duration)}"
13
+ >
14
+ ${formatTime(duration)}
15
+ </time>
16
+ `;
17
+ });
@@ -0,0 +1,14 @@
1
+ import { virtual } from 'haunted';
2
+ import { html } from 'lit';
3
+ import { CoverFallback } from '../../cover-fallback/index.js';
4
+ import { getCover } from '../../../_lib/get-cover.js';
5
+
6
+ export const ShowCover = virtual(({ cover }) => {
7
+ return html`
8
+ <div class="myth-playlist__item-block myth-playlist__item-image">
9
+ ${cover
10
+ ? html`<img src="${getCover(cover)}" alt="" aria-hidden="true" />`
11
+ : CoverFallback({ isInFileItem: true })}
12
+ </div>
13
+ `;
14
+ });
@@ -0,0 +1,13 @@
1
+ const componentToHex = channel => {
2
+ const hex = channel.toString(16);
3
+ return hex.length === 1 ? '0' + hex : hex;
4
+ };
5
+
6
+ export const rgbToHex = item => {
7
+ return (
8
+ '#' +
9
+ componentToHex(item.r) +
10
+ componentToHex(item.g) +
11
+ componentToHex(item.b)
12
+ ).toUpperCase();
13
+ };
@@ -0,0 +1,29 @@
1
+ import { getCover } from './get-cover.js';
2
+
3
+ export function setupMediaSession({ index, playlist, handleAudio }) {
4
+ const metadata = playlist[index];
5
+ const type = 'image/jpeg';
6
+ navigator.mediaSession.metadata = new MediaMetadata({
7
+ title: metadata.title,
8
+ artist: metadata.artist,
9
+ album: metadata.album,
10
+ artwork: ['512', '384', '256', '192', '128', '96'].map(size => {
11
+ return {
12
+ src: metadata.cover ? getCover(metadata.cover) : null,
13
+ sizes: `${size}x${size}`,
14
+ type
15
+ };
16
+ })
17
+ });
18
+ navigator.mediaSession.setActionHandler(
19
+ 'nexttrack',
20
+ index < playlist.length - 1 ? handleAudio.next : null
21
+ );
22
+ navigator.mediaSession.setActionHandler(
23
+ 'previoustrack',
24
+ index > 0 ? handleAudio.previous : null
25
+ );
26
+ navigator.mediaSession.setActionHandler('play', handleAudio.play);
27
+ navigator.mediaSession.setActionHandler('pause', handleAudio.pause);
28
+ navigator.mediaSession.setActionHandler('stop', handleAudio.stop);
29
+ }
@@ -0,0 +1,42 @@
1
+ import {
2
+ isEventClick,
3
+ isKeyArrowLeft,
4
+ isKeyArrowRight
5
+ } from 'a11y-key-conjurer';
6
+
7
+ const SKIP_TIME = 10;
8
+
9
+ function handleClick(ev, getMediaElement) {
10
+ const audioElement = getMediaElement();
11
+ const target = ev.currentTarget;
12
+ const rect = target.getBoundingClientRect();
13
+ const x = ev.clientX - rect.left;
14
+ const percent = (100 * x) / target.offsetWidth;
15
+ audioElement.currentTime = (audioElement.duration / 100) * percent;
16
+ }
17
+
18
+ function handleArrows({ multiplier }, getMediaElement) {
19
+ const audioElement = getMediaElement();
20
+ const newTime = audioElement.currentTime + SKIP_TIME * multiplier;
21
+ const duration = audioElement.duration;
22
+ audioElement.currentTime =
23
+ newTime > 0 ? (newTime < duration ? newTime : duration) : 0;
24
+ }
25
+
26
+ function handleArrowLeft(ev, getMediaElement) {
27
+ handleArrows({ ev, multiplier: -1 }, getMediaElement);
28
+ }
29
+
30
+ function handleArrowRight(ev, getMediaElement) {
31
+ handleArrows({ ev, multiplier: 1 }, getMediaElement);
32
+ }
33
+
34
+ export function handleTimeline(ev, getMediaElement) {
35
+ if (isEventClick(ev)) {
36
+ handleClick(ev, getMediaElement);
37
+ } else if (isKeyArrowLeft(ev)) {
38
+ handleArrowLeft(ev, getMediaElement);
39
+ } else if (isKeyArrowRight(ev)) {
40
+ handleArrowRight(ev, getMediaElement);
41
+ }
42
+ }
@@ -0,0 +1,121 @@
1
+ import { html, nothing } from 'lit';
2
+ import { component, useState } from 'haunted';
3
+ import { classMap } from 'lit/directives/class-map.js';
4
+ import { handleTimeline } from './_lib/handle-timeline.js';
5
+ import { readable as formatTime } from 'mystic-format-time';
6
+
7
+ function getChapter({ targetTime, chapters }) {
8
+ const selected = chapters.find(
9
+ chapter =>
10
+ targetTime >= chapter.start / 1000 && targetTime < chapter.end / 1000
11
+ );
12
+ return selected?.tags?.title || null;
13
+ }
14
+
15
+ function TimelineComponent(element) {
16
+ const { props } = element;
17
+ const {
18
+ totalTime,
19
+ currentTime = 0, // keep currentTime here for the INITIAL render, but we won't rely on it for updates
20
+ currentItem = {},
21
+ getMediaElement,
22
+ id
23
+ } = props;
24
+
25
+ const [timePosition, setTimePosition] = useState(null);
26
+ const chapters = currentItem?.metadata?.chapters || [];
27
+
28
+ function handleTimelineIn(ev) {
29
+ const target = ev.currentTarget;
30
+ const leftPosition = ev.layerX;
31
+ const targetWidth = target.offsetWidth;
32
+ const targetTime = (leftPosition * totalTime) / targetWidth;
33
+ setTimePosition({
34
+ visual: formatTime(targetTime),
35
+ chapter:
36
+ chapters.length > 0 ? getChapter({ targetTime, chapters }) : null,
37
+ left: leftPosition
38
+ });
39
+ }
40
+
41
+ function handleTimelineOut() {
42
+ setTimePosition(null);
43
+ }
44
+
45
+ function fnHandleTimeline(ev) {
46
+ handleTimeline(ev, getMediaElement);
47
+ }
48
+
49
+ return html`
50
+ <div class="timeline">
51
+ <div
52
+ class="timeline__track"
53
+ @click="${fnHandleTimeline}"
54
+ @keydown="${fnHandleTimeline}"
55
+ @mousemove="${handleTimelineIn}"
56
+ @mouseout="${handleTimelineOut}"
57
+ role="progressbar"
58
+ tabindex="0"
59
+ >
60
+ <div
61
+ id="bar-${id}"
62
+ class="timeline__completed"
63
+ style="width: 0;"
64
+ ></div>
65
+ </div>
66
+ <div class="timeline__numbers">
67
+ <div id="txt-${id}">00:00</div>
68
+ <div>${formatTime(totalTime)}</div>
69
+ </div>
70
+
71
+ ${chapters.map(chapter => {
72
+ const limitClasses = {
73
+ 'timeline__chapter-limit': true,
74
+ 'timeline__chapter-limit--played': currentTime > chapter.start / 1000
75
+ };
76
+ const position = (100 / totalTime) * (chapter.start / 1000);
77
+ return html`
78
+ <div
79
+ class="${classMap(limitClasses)}"
80
+ style="--timeline--chapter-limit-position: ${position}%;"
81
+ ></div>
82
+ `;
83
+ })}
84
+ ${timePosition === null
85
+ ? nothing
86
+ : html`
87
+ <div
88
+ class="timeline__tooltip"
89
+ style="--timeline-tooltip-position: ${timePosition.left}px;"
90
+ >
91
+ <div class="timeline__tooltip-inner">
92
+ ${timePosition.chapter
93
+ ? html`<span class="timeline__tooltip-chapter-title"
94
+ >${timePosition.chapter}</span
95
+ >`
96
+ : nothing}
97
+ <span class="timeline__tooltip-time"
98
+ >${timePosition.visual}</span
99
+ >
100
+ </div>
101
+ </div>
102
+ `}
103
+ </div>
104
+ `;
105
+ }
106
+
107
+ if (!customElements.get('mythical-player-timeline')) {
108
+ customElements.define(
109
+ 'mythical-player-timeline',
110
+ component(TimelineComponent, {
111
+ observedAttributes: [],
112
+ useShadowDOM: false
113
+ })
114
+ );
115
+ }
116
+
117
+ export function Timeline(props) {
118
+ return html`<mythical-player-timeline
119
+ .props="${props}"
120
+ ></mythical-player-timeline>`;
121
+ }
@@ -0,0 +1,36 @@
1
+ import { virtual } from 'haunted';
2
+ import { html } from 'lit';
3
+ import { CoverFallback } from '../cover-fallback/index.js';
4
+ import { TrackText } from '../track-text/index.js';
5
+ import { getCover } from '../../_lib/get-cover.js';
6
+
7
+ export const TrackInfo = virtual(
8
+ ({ track = {}, indexSelected, literals = {} }) => {
9
+ const hasTrackInfo = Number.isInteger(indexSelected);
10
+ return html`
11
+ <div class="myth-track-info">
12
+ <div class="myth-track-info__inner">
13
+ <div class="myth-track-info__image">
14
+ ${hasTrackInfo && track.cover
15
+ ? html`<img
16
+ src="${getCover(track.cover)}"
17
+ alt=""
18
+ aria-hidden="true"
19
+ />`
20
+ : CoverFallback({})}
21
+ </div>
22
+ <div class="myth-track-info__data">
23
+ ${hasTrackInfo
24
+ ? TrackText({
25
+ title: track.title,
26
+ artist: track.artist,
27
+ album: track.album,
28
+ hasTrackInfo
29
+ })
30
+ : TrackText({ title: literals.noPlaying, hasTrackInfo })}
31
+ </div>
32
+ </div>
33
+ </div>
34
+ `;
35
+ }
36
+ );
@@ -0,0 +1,20 @@
1
+ import { virtual } from 'haunted';
2
+ import { html, nothing } from 'lit';
3
+
4
+ export const TrackText = virtual(({ artist, album, title, hasTrackInfo }) => {
5
+ return html`
6
+ <div class="myth-track-text">
7
+ <div
8
+ class="myth-item-text"
9
+ data-test-id="player-data-${hasTrackInfo ? 'title' : 'stopped'}"
10
+ >
11
+ ${title}
12
+ </div>
13
+ <div class="myth-item-text size-small">
14
+ ${artist ? html`<span>${artist}</span>` : nothing}
15
+ ${artist && album ? html`<span> / </span>` : nothing}
16
+ ${album ? html`<span>${album}</span>` : nothing}
17
+ </div>
18
+ </div>
19
+ `;
20
+ });
@@ -0,0 +1,5 @@
1
+ $player-widht-s: 640px;
2
+ $player-widht-m: 768px;
3
+ $player-widht-l: 1024px;
4
+ $player-widht-xl: 1280px;
5
+ $player-widht-2xl: 1536px;
@@ -0,0 +1,50 @@
1
+ @use '@outbook/design-decisions/measures';
2
+ @use '@outbook/design-decisions/outline';
3
+ @use '../_lib/variables';
4
+
5
+ @mixin style() {
6
+ .myth-controls {
7
+ --_controls-color: light-dark(
8
+ var(--_accent-950),
9
+ var(--_accent-100)
10
+ );
11
+ --_controls-size: #{measures.$baseline * 6};
12
+ }
13
+
14
+ @container player (width >= #{variables.$player-widht-m}) {
15
+ .myth-controls {
16
+ --_controls-size: #{measures.$baseline * 7};
17
+ }
18
+ }
19
+
20
+ @container player (width >= #{variables.$player-widht-l}) {
21
+ .myth-controls {
22
+ --_controls-size: #{measures.$baseline * 8};
23
+ }
24
+ }
25
+
26
+ .myth-controls,
27
+ .myth-controls__items {
28
+ width: auto;
29
+ }
30
+
31
+ .myth-controls__items {
32
+ display: flex;
33
+ }
34
+
35
+ .myth-controls__button {
36
+ background: transparent;
37
+ border: 0;
38
+ color: var(--_controls-color);
39
+ &:focus-visible {
40
+ @include outline.inside();
41
+ }
42
+ &.is-disabled {
43
+ opacity: 0.4;
44
+ }
45
+ }
46
+ .myth-controls__icon {
47
+ width: var(--_controls-size);
48
+ height: var(--_controls-size);
49
+ }
50
+ }
@@ -0,0 +1,52 @@
1
+ @use '@outbook/design-decisions/measures';
2
+ @use '../_lib/variables';
3
+
4
+ @mixin style {
5
+ mythical-cover-fallback {
6
+ display: block;
7
+ }
8
+
9
+ .cover-fallback {
10
+ --_cover-fallback-bg-color: light-dark(var(--_accent-200), var(--_accent-700));
11
+ --_cover-fallback-font-size: #{measures.$baseline * 4};
12
+
13
+ &.cover-fallback--in-file-item {
14
+ --_cover-fallback-font-size: #{measures.$baseline * 4};
15
+ }
16
+ }
17
+ @container player (width >= #{variables.$player-widht-m}) {
18
+ .cover-fallback {
19
+ --_cover-fallback-font-size: #{measures.$baseline * 20};
20
+ &.cover-fallback--in-file-item {
21
+ --_cover-fallback-font-size: #{measures.$baseline * 4};
22
+ }
23
+ }
24
+ }
25
+
26
+ .cover-fallback {
27
+ width: inherit;
28
+ padding-top: 100%;
29
+ background-color: var(--_cover-fallback-bg-color);
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ position: relative;
34
+ }
35
+
36
+ .cover-fallback__icon {
37
+ width: 80%;
38
+ height: 80%;
39
+ position: absolute;
40
+ top: 50%;
41
+ left: 50%;
42
+ transform: translate(-50%, -50%);
43
+ color: var(--_text-color);
44
+ display: flex;
45
+ justify-content: center;
46
+ align-items: center;
47
+ }
48
+ .cover-fallback__icon-inner {
49
+ width: 100%;
50
+ height: 100%;
51
+ }
52
+ }
@@ -0,0 +1,119 @@
1
+ @use '@outbook/design-decisions/measures';
2
+ @use '@outbook/design-decisions/animation-pulse';
3
+ @use '@outbook/design-decisions/outline';
4
+ @use '../_lib/variables';
5
+
6
+ @mixin style() {
7
+ .myth-playlist {
8
+ --_playlist-font-weight: normal;
9
+ --_playlist-font-size: #{measures.$baseline * 1.5};
10
+ --_playlist-font-size-small: #{measures.$baseline * 1.25};
11
+ --_playlist-item-background-color: transparent;
12
+ --_playlist-item-image-size: #{measures.$baseline * 6};
13
+ .is-playing {
14
+ --_playlist-font-weight: 600;
15
+ --_playlist-item-background-color: var(--_accent-200);
16
+ .ambient-dark & {
17
+ --_playlist-item-background-color: var(--_accent-700);
18
+ }
19
+ }
20
+ }
21
+
22
+ @container player (width >= #{variables.$player-widht-l}) {
23
+ .myth-playlist {
24
+ --_playlist-font-size: #{measures.$baseline * 2};
25
+ --_playlist-font-size-small: #{measures.$baseline * 1.5};
26
+ --_playlist-item-image-size: #{measures.$baseline * 8};
27
+ }
28
+ }
29
+
30
+ .myth-playlist {
31
+ .myth-item-text {
32
+ font-weight: var(--_playlist-font-weight);
33
+ font-size: var(--_playlist-font-size);
34
+ line-height: 150%;
35
+ &.size-small {
36
+ font-size: var(--_playlist-font-size-small);
37
+ }
38
+ }
39
+ }
40
+
41
+ .myth-playlist__items {
42
+ padding: 0;
43
+ position: relative;
44
+ }
45
+
46
+ .myth-playlist__item {
47
+ position: relative;
48
+ border-bottom: 1px solid var(--_separator-color);
49
+ &.is-playing {
50
+ &:before {
51
+ content: "";
52
+ position: absolute;
53
+ top: 0;
54
+ left: 0;
55
+ width: 100%;
56
+ height: 100%;
57
+ opacity: 0.45;
58
+ @include animation-pulse.background();
59
+ }
60
+ }
61
+ &:last-child {
62
+ border-bottom: 0;
63
+ }
64
+ }
65
+
66
+ .myth-playlist__item-button {
67
+ display: flex;
68
+ padding: #{measures.$baseline};
69
+ position: relative;
70
+ align-items: center;
71
+ &:focus-visible {
72
+ @include outline.inside();
73
+ }
74
+ }
75
+
76
+ .myth-playlist__item-group,
77
+ .myth-playlist__item-block {
78
+ margin-left: #{measures.$baseline * 2};
79
+ &:first-child {
80
+ margin-left: 0;
81
+ }
82
+ &.is-last-right {
83
+ margin-left: auto;
84
+ padding-left: #{measures.$baseline};
85
+ }
86
+ }
87
+
88
+ .myth-playlist__item-group {
89
+ display: flex;
90
+ align-items: center;
91
+ }
92
+
93
+ .myth-playlist__item-image {
94
+ width: var(--_playlist-item-image-size);
95
+ height: var(--_playlist-item-image-size);
96
+ position: relative;
97
+ overflow: hidden;
98
+ flex-shrink: 0;
99
+ img {
100
+ height: 100%;
101
+ position: absolute;
102
+ top: 0;
103
+ left: 50%;
104
+ transform: translateX(-50%);
105
+ }
106
+ }
107
+
108
+ .myth-playlist__item-playing-icon {
109
+ width: #{measures.$baseline * 2};
110
+ height: #{measures.$baseline * 2};
111
+ display: flex;
112
+ align-items: center;
113
+ }
114
+
115
+ .myth-playlist__item-playing-icon-inner {
116
+ width: 100%;
117
+ aspect-ratio: 1 / 1;
118
+ }
119
+ }