diff --git a/src/apps/experimental/routes/legacyRoutes/user.ts b/src/apps/experimental/routes/legacyRoutes/user.ts index 1547f683590..12e137bc96f 100644 --- a/src/apps/experimental/routes/legacyRoutes/user.ts +++ b/src/apps/experimental/routes/legacyRoutes/user.ts @@ -13,6 +13,12 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [ controller: 'list', view: 'list.html' } + }, { + path: 'lyrics', + pageProps: { + controller: 'lyrics', + view: 'lyrics.html' + } }, { path: 'mypreferencesmenu.html', pageProps: { diff --git a/src/apps/stable/routes/legacyRoutes/user.ts b/src/apps/stable/routes/legacyRoutes/user.ts index fa1fa43ad2d..19b87c7cd85 100644 --- a/src/apps/stable/routes/legacyRoutes/user.ts +++ b/src/apps/stable/routes/legacyRoutes/user.ts @@ -19,6 +19,12 @@ export const LEGACY_USER_ROUTES: LegacyRoute[] = [ controller: 'livetv/livetvsuggested', view: 'livetv.html' } + }, { + path: 'lyrics', + pageProps: { + controller: 'lyrics', + view: 'lyrics.html' + } }, { path: 'music.html', pageProps: { diff --git a/src/components/itemContextMenu.js b/src/components/itemContextMenu.js index c056b0d0ece..fd7d82b42e5 100644 --- a/src/components/itemContextMenu.js +++ b/src/components/itemContextMenu.js @@ -183,6 +183,14 @@ export function getCommands(options) { id: 'delete', icon: 'delete' }); + + if (item.Type === 'Audio' && item.HasLyrics && window.location.href.includes(item.Id)) { + commands.push({ + name: globalize.translate('DeleteLyrics'), + id: 'deleteLyrics', + icon: 'delete_sweep' + }); + } } // Books are promoted to major download Button and therefor excluded in the context menu @@ -313,6 +321,14 @@ export function getCommands(options) { }); } + if (item.HasLyrics) { + commands.push({ + name: globalize.translate('ViewLyrics'), + id: 'lyrics', + icon: 'lyrics' + }); + } + return commands; } @@ -495,6 +511,9 @@ function executeCommand(item, id, options) { case 'delete': deleteItem(apiClient, item).then(getResolveFunction(resolve, id, true, true), getResolveFunction(resolve, id)); break; + case 'deleteLyrics': + deleteLyrics(apiClient, item).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id)); + break; case 'share': navigator.share({ title: item.Name, @@ -510,6 +529,15 @@ function executeCommand(item, id, options) { appRouter.showItem(item.AlbumArtists[0].Id, item.ServerId); getResolveFunction(resolve, id)(); break; + case 'lyrics': { + if (options.isMobile) { + appRouter.show('lyrics'); + } else { + appRouter.showItem(item.Id, item.ServerId); + } + getResolveFunction(resolve, id)(); + break; + } case 'playallfromhere': getResolveFunction(resolve, id)(); break; @@ -636,6 +664,12 @@ function deleteItem(apiClient, item) { }); } +function deleteLyrics(apiClient, item) { + return import('../scripts/deleteHelper').then((deleteHelper) => { + return deleteHelper.deleteLyrics(item); + }); +} + function refresh(apiClient, item) { import('./refreshdialog/refreshdialog').then(({ default: RefreshDialog }) => { new RefreshDialog({ diff --git a/src/components/nowPlayingBar/nowPlayingBar.js b/src/components/nowPlayingBar/nowPlayingBar.js index cdeaea0cca3..ab3c9c7feb8 100644 --- a/src/components/nowPlayingBar/nowPlayingBar.js +++ b/src/components/nowPlayingBar/nowPlayingBar.js @@ -34,6 +34,7 @@ let positionSlider; let toggleAirPlayButton; let toggleRepeatButton; let toggleRepeatButtonIcon; +let lyricButton; let lastUpdateTime = 0; let lastPlayerState = {}; @@ -42,6 +43,9 @@ let currentRuntimeTicks = 0; let isVisibilityAllowed = true; +let lyricPageActive = false; +let isAudio = false; + function getNowPlayingBarHtml() { let html = ''; @@ -82,6 +86,8 @@ function getNowPlayingBarHtml() { html += ``; + html += ``; + html += ``; html += ``; @@ -146,6 +152,7 @@ function bindEvents(elem) { toggleRepeatButton = elem.querySelector('.toggleRepeatButton'); volumeSlider = elem.querySelector('.nowPlayingBarVolumeSlider'); volumeSliderContainer = elem.querySelector('.nowPlayingBarVolumeSliderContainer'); + lyricButton = nowPlayingBarElement.querySelector('.openLyricsButton'); muteButton.addEventListener('click', function () { if (currentPlayer) { @@ -212,6 +219,14 @@ function bindEvents(elem) { } }); + lyricButton.addEventListener('click', function() { + if (lyricPageActive) { + appRouter.back(); + } else { + appRouter.show('lyrics'); + } + }); + toggleRepeatButton = elem.querySelector('.toggleRepeatButton'); toggleRepeatButton.addEventListener('click', function () { switch (playbackManager.getRepeatMode()) { @@ -363,6 +378,7 @@ function updatePlayerStateInternal(event, state, player) { updateTimeDisplay(playState.PositionTicks, nowPlayingItem.RunTimeTicks, playbackManager.getBufferedRanges(player)); updateNowPlayingInfo(state); + updateLyricButton(); } function updateRepeatModeDisplay(repeatMode) { @@ -453,6 +469,22 @@ function updatePlayerVolumeState(isMuted, volumeLevel) { } } +function updateLyricButton() { + if (!isEnabled) { + return; + } + + isAudio ? showButton(lyricButton) : hideButton(lyricButton); + setLyricButtonActiveStatus(); +} + +function setLyricButtonActiveStatus() { + if (!isEnabled) { + return; + } + lyricButton.classList.toggle('buttonActive', lyricPageActive); +} + function seriesImageUrl(item, options) { if (!item) { throw new Error('item cannot be null!'); @@ -595,6 +627,9 @@ function updateNowPlayingInfo(state) { function onPlaybackStart(e, state) { console.debug('nowplaying event: ' + e.type); const player = this; + + isAudio = state.NowPlayingItem.Type === 'Audio'; + onStateChanged.call(player, e, state); } @@ -698,6 +733,7 @@ function onStateChanged(event, state) { } getNowPlayingBar(); + updateLyricButton(); updatePlayerStateInternal(event, state, player); } @@ -754,6 +790,7 @@ function refreshFromPlayer(player, type) { } function bindToPlayer(player) { + lyricPageActive = appRouter.currentRouteInfo.path.toLowerCase() === '/lyrics'; if (player === currentPlayer) { return; } @@ -786,6 +823,8 @@ Events.on(playbackManager, 'playerchange', function () { bindToPlayer(playbackManager.getCurrentPlayer()); document.addEventListener('viewbeforeshow', function (e) { + lyricPageActive = appRouter.currentRouteInfo.path.toLowerCase() === '/lyrics'; + setLyricButtonActiveStatus(); if (!e.detail.options.enableMediaControl) { if (isVisibilityAllowed) { isVisibilityAllowed = false; diff --git a/src/components/remotecontrol/remotecontrol.js b/src/components/remotecontrol/remotecontrol.js index 9406e16e2b3..2de6f2e2065 100644 --- a/src/components/remotecontrol/remotecontrol.js +++ b/src/components/remotecontrol/remotecontrol.js @@ -222,7 +222,8 @@ function updateNowPlayingInfo(context, state, serverId) { contextButton.addEventListener('click', function () { itemContextMenu.show(Object.assign({ item: fullItem, - user: user + user: user, + isMobile: layoutManager.mobile }, options)) .catch(() => { /* no-op */ }); }); @@ -323,6 +324,7 @@ export default function () { context.querySelector('.remoteControlSection').classList.add('hide'); } + buttonVisible(context.querySelector('.btnLyrics'), item?.Type === 'Audio' && !layoutManager.mobile); buttonVisible(context.querySelector('.btnStop'), item != null); buttonVisible(context.querySelector('.btnNextTrack'), item != null); buttonVisible(context.querySelector('.btnPreviousTrack'), item != null); @@ -769,6 +771,10 @@ export default function () { playbackManager.fastForward(currentPlayer); } }); + context.querySelector('.btnLyrics').addEventListener('click', function () { + appRouter.show('lyrics'); + }); + for (const shuffleButton of context.querySelectorAll('.btnShuffleQueue')) { shuffleButton.addEventListener('click', function () { if (currentPlayer) { diff --git a/src/controllers/itemDetails/index.html b/src/controllers/itemDetails/index.html index 0172fe659e5..935ac44ea48 100644 --- a/src/controllers/itemDetails/index.html +++ b/src/controllers/itemDetails/index.html @@ -187,6 +187,11 @@

+
+

${Lyrics}

+
+
+

diff --git a/src/controllers/itemDetails/index.js b/src/controllers/itemDetails/index.js index e169a82c3ae..f5cf971eba5 100644 --- a/src/controllers/itemDetails/index.js +++ b/src/controllers/itemDetails/index.js @@ -1,7 +1,7 @@ import { intervalToDuration } from 'date-fns'; import DOMPurify from 'dompurify'; -import markdownIt from 'markdown-it'; import escapeHtml from 'escape-html'; +import markdownIt from 'markdown-it'; import isEqual from 'lodash-es/isEqual'; import { appHost } from 'components/apphost'; @@ -1055,6 +1055,7 @@ function renderDetails(page, item, apiClient, context) { renderOverview(page, item); renderMiscInfo(page, item); reloadUserDataButtons(page, item); + renderLyricsContainer(page, item, apiClient); // Don't allow redirection to other websites from the TV layout if (!layoutManager.tv && appHost.supports('externallinks')) { @@ -1069,6 +1070,38 @@ function enableScrollX() { return browser.mobile && window.screen.availWidth <= 1000; } +function renderLyricsContainer(view, item, apiClient) { + const lyricContainer = view.querySelector('.lyricsContainer'); + if (lyricContainer && item.HasLyrics) { + if (item.Type !== 'Audio') { + lyricContainer.classList.add('hide'); + return; + } + //get lyrics + apiClient.ajax({ + url: apiClient.getUrl('Audio/' + item.Id + '/Lyrics'), + type: 'GET', + dataType: 'json' + }).then((response) => { + if (!response.Lyrics) { + lyricContainer.classList.add('hide'); + return; + } + lyricContainer.classList.remove('hide'); + const itemsContainer = lyricContainer.querySelector('.itemsContainer'); + if (itemsContainer) { + const html = response.Lyrics.reduce((htmlAccumulator, lyric) => { + htmlAccumulator += escapeHtml(lyric.Text) + '
'; + return htmlAccumulator; + }, ''); + itemsContainer.innerHTML = html; + } + }).catch(() => { + lyricContainer.classList.add('hide'); + }); + } +} + function renderMoreFromSeason(view, item, apiClient) { const section = view.querySelector('.moreFromSeasonSection'); @@ -1119,7 +1152,7 @@ function renderMoreFromArtist(view, item, apiClient) { const section = view.querySelector('.moreFromArtistSection'); if (section) { - if (item.Type !== 'MusicArtist' && (item.Type !== 'MusicAlbum' || !item.AlbumArtists || !item.AlbumArtists.length)) { + if (item.Type !== 'MusicArtist' && item.Type !== 'Audio' && (item.Type !== 'MusicAlbum' || !item.AlbumArtists || !item.AlbumArtists.length)) { section.classList.add('hide'); return; } @@ -1174,7 +1207,7 @@ function renderSimilarItems(page, item, context) { const similarCollapsible = page.querySelector('#similarCollapsible'); if (similarCollapsible) { - if (item.Type != 'Movie' && item.Type != 'Trailer' && item.Type != 'Series' && item.Type != 'Program' && item.Type != 'Recording' && item.Type != 'MusicAlbum' && item.Type != 'MusicArtist' && item.Type != 'Playlist') { + if (item.Type != 'Movie' && item.Type != 'Trailer' && item.Type != 'Series' && item.Type != 'Program' && item.Type != 'Recording' && item.Type != 'MusicAlbum' && item.Type != 'MusicArtist' && item.Type != 'Playlist' && item.Type != 'Audio') { similarCollapsible.classList.add('hide'); return; } diff --git a/src/controllers/lyrics.html b/src/controllers/lyrics.html new file mode 100644 index 00000000000..d1e7b662cf9 --- /dev/null +++ b/src/controllers/lyrics.html @@ -0,0 +1,6 @@ +
+
+
+
+
+
diff --git a/src/controllers/lyrics.js b/src/controllers/lyrics.js new file mode 100644 index 00000000000..d0107406e38 --- /dev/null +++ b/src/controllers/lyrics.js @@ -0,0 +1,250 @@ +import escapeHtml from 'escape-html'; + +import autoFocuser from 'components/autoFocuser'; +import { appRouter } from '../components/router/appRouter'; +import layoutManager from 'components/layoutManager'; +import { playbackManager } from '../components/playback/playbackmanager'; +import ServerConnections from '../components/ServerConnections'; + +import globalize from '../scripts/globalize'; +import LibraryMenu from '../scripts/libraryMenu'; +import Events from '../utils/events.ts'; + +import '../styles/lyrics.scss'; + +let currentPlayer; +let currentItem; + +let savedLyrics; +let isDynamicLyric = false; + +function dynamicLyricHtmlReducer(htmlAccumulator, lyric, index) { + if (layoutManager.tv) { + htmlAccumulator += ``; + } else { + htmlAccumulator += `
${escapeHtml(lyric.Text)}
`; + } + return htmlAccumulator; +} + +function staticLyricHtmlReducer(htmlAccumulator, lyric, index) { + if (layoutManager.tv) { + htmlAccumulator += ``; + } else { + htmlAccumulator += `
${escapeHtml(lyric.Text)}
`; + } + return htmlAccumulator; +} + +function getLyricIndex(time, lyrics) { + return lyrics.findLastIndex(lyric => lyric.Start <= time); +} + +function getCurrentPlayTime() { + let currentTime = playbackManager.currentTime(); + if (currentTime === undefined) currentTime = 0; + //convert to ticks + return currentTime * 10000; +} + +export default function (view) { + function setPastLyricClassOnLine(line) { + const lyric = view.querySelector(`#lyricPosition${line}`); + if (lyric) { + lyric.classList.remove('futureLyric'); + lyric.classList.add('pastLyric'); + } + } + + function setFutureLyricClassOnLine(line) { + const lyric = view.querySelector(`#lyricPosition${line}`); + if (lyric) { + lyric.classList.remove('pastLyric'); + lyric.classList.add('futureLyric'); + } + } + + function setCurrentLyricClassOnLine(line) { + const lyric = view.querySelector(`#lyricPosition${line}`); + if (lyric) { + lyric.classList.remove('pastLyric'); + lyric.classList.remove('futureLyric'); + } + } + + function updateAllLyricLines(currentLine, lyrics) { + for (let lyricIndex = 0; lyricIndex <= lyrics.length; lyricIndex++) { + if (lyricIndex < currentLine) { + setPastLyricClassOnLine(lyricIndex); + } else if (lyricIndex === currentLine) { + setCurrentLyricClassOnLine(lyricIndex); + } else if (lyricIndex > currentLine) { + setFutureLyricClassOnLine(lyricIndex); + } + } + } + + function renderNoLyricMessage() { + const itemsContainer = view.querySelector('.dynamicLyricsContainer'); + if (itemsContainer) { + const html = `

${globalize.translate('HeaderNoLyrics')}

`; + itemsContainer.innerHTML = html; + } + autoFocuser.autoFocus(); + } + + function renderDynamicLyrics(lyrics) { + const itemsContainer = view.querySelector('.dynamicLyricsContainer'); + if (itemsContainer) { + const html = lyrics.reduce(dynamicLyricHtmlReducer, ''); + itemsContainer.innerHTML = html; + } + + const lyricLineArray = itemsContainer.querySelectorAll('.lyricsLine'); + + // attaches click event listener to change playtime to lyric start + lyricLineArray.forEach(element => { + element.addEventListener('click', () => onLyricClick(element.getAttribute('data-lyrictime'))); + }); + + const currentIndex = getLyricIndex(getCurrentPlayTime(), lyrics); + updateAllLyricLines(currentIndex, savedLyrics); + } + + function renderStaticLyrics(lyrics) { + const itemsContainer = view.querySelector('.dynamicLyricsContainer'); + if (itemsContainer) { + const html = lyrics.reduce(staticLyricHtmlReducer, ''); + itemsContainer.innerHTML = html; + } + } + + function updateLyrics(lyrics) { + savedLyrics = lyrics; + + isDynamicLyric = Object.prototype.hasOwnProperty.call(lyrics[0], 'Start'); + + if (isDynamicLyric) { + renderDynamicLyrics(savedLyrics); + } else { + renderStaticLyrics(savedLyrics); + } + + autoFocuser.autoFocus(view); + } + + function getLyrics(serverId, itemId) { + const apiClient = ServerConnections.getApiClient(serverId); + + return apiClient.ajax({ + url: apiClient.getUrl('Audio/' + itemId + '/Lyrics'), + type: 'GET', + dataType: 'json' + }).then((response) => { + if (!response.Lyrics) { + throw new Error(); + } + return response.Lyrics; + }); + } + + function bindToPlayer(player) { + if (player === currentPlayer) { + return; + } + + releaseCurrentPlayer(); + + currentPlayer = player; + + if (!player) { + return; + } + + Events.on(player, 'timeupdate', onTimeUpdate); + Events.on(player, 'playbackstart', onPlaybackStart); + Events.on(player, 'playbackstop', onPlaybackStop); + } + + function releaseCurrentPlayer() { + const player = currentPlayer; + + if (player) { + Events.off(player, 'timeupdate', onTimeUpdate); + Events.off(player, 'playbackstart', onPlaybackStart); + Events.off(player, 'playbackstop', onPlaybackStop); + currentPlayer = null; + } + } + + function onLyricClick(lyricTime) { + playbackManager.seek(lyricTime); + if (playbackManager.paused()) { + playbackManager.playPause(currentPlayer); + } + } + + function onTimeUpdate() { + if (isDynamicLyric) { + const currentIndex = getLyricIndex(getCurrentPlayTime(), savedLyrics); + updateAllLyricLines(currentIndex, savedLyrics); + } + } + + function onPlaybackStart(event, state) { + if (currentItem.Id !== state.NowPlayingItem.Id) { + onLoad(); + } + } + + function onPlaybackStop(_, state) { + // TODO: switch to appRouter.back(), with fix to navigation to /#/queue. Which is broken when it has nothing playing + if (!state.NextMediaType) { + appRouter.goHome(); + } + } + + function onPlayerChange() { + const player = playbackManager.getCurrentPlayer(); + bindToPlayer(player); + } + + function onLoad() { + savedLyrics = null; + currentItem = null; + isDynamicLyric = false; + + LibraryMenu.setTitle(globalize.translate('Lyrics')); + + const player = playbackManager.getCurrentPlayer(); + + if (player) { + bindToPlayer(player); + + const state = playbackManager.getPlayerState(player); + currentItem = state.NowPlayingItem; + + const serverId = state.NowPlayingItem.ServerId; + const itemId = state.NowPlayingItem.Id; + + getLyrics(serverId, itemId).then(updateLyrics).catch(renderNoLyricMessage); + } else { + // if nothing is currently playing, no lyrics to display redirect to home + appRouter.goHome(); + } + } + + view.addEventListener('viewshow', function () { + Events.on(playbackManager, 'playerchange', onPlayerChange); + try { + onLoad(); + } catch (e) { + appRouter.goHome(); + } + }); + + view.addEventListener('viewbeforehide', function () { + Events.off(playbackManager, 'playerchange', onPlayerChange); + releaseCurrentPlayer(); + }); +} diff --git a/src/controllers/playback/queue/index.html b/src/controllers/playback/queue/index.html index cb4706f152a..bb3a85cd0af 100644 --- a/src/controllers/playback/queue/index.html +++ b/src/controllers/playback/queue/index.html @@ -81,6 +81,10 @@

+ + diff --git a/src/scripts/deleteHelper.js b/src/scripts/deleteHelper.js index e1857e33f0c..58a31045cbc 100644 --- a/src/scripts/deleteHelper.js +++ b/src/scripts/deleteHelper.js @@ -1,9 +1,9 @@ +import globalize from './globalize'; +import alert from '../components/alert'; import confirm from '../components/confirm/confirm'; import { appRouter } from '../components/router/appRouter'; -import globalize from './globalize'; import ServerConnections from '../components/ServerConnections'; -import alert from '../components/alert'; import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; function alertText(options) { @@ -54,6 +54,28 @@ export function deleteItem(options) { }); } +export function deleteLyrics (item) { + return confirm({ + title: globalize.translate('HeaderDeleteLyrics'), + text: globalize.translate('ConfirmDeleteLyrics'), + confirmText: globalize.translate('Delete'), + primary: 'delete' + }).then(() => { + const apiClient = ServerConnections.getApiClient(item.ServerId); + return apiClient.ajax({ + url: apiClient.getUrl('Audio/' + item.Id + '/Lyrics'), + type: 'DELETE' + }).catch((err) => { + const result = function () { + return Promise.reject(err); + }; + + return alertText(globalize.translate('ErrorDeletingLyrics')).then(result, result); + }); + }); +} + export default { - deleteItem: deleteItem + deleteItem, + deleteLyrics }; diff --git a/src/strings/en-us.json b/src/strings/en-us.json index f593993fbd0..bb2c5e85e9c 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -162,6 +162,7 @@ "ConfirmDeleteItem": "Deleting this item will delete it from both the file system and your media library. Are you sure you wish to continue?", "ConfirmDeleteSeries": "Deleting this series will delete ALL {0} episodes from both the file system and your media library. Are you sure you wish to continue?", "ConfirmDeleteItems": "Deleting these items will delete them from both the file system and your media library. Are you sure you wish to continue?", + "ConfirmDeleteLyrics": "Deleting these lyrics will delete them from both the file system and your media library. Are you sure you wish to continue?", "ConfirmDeletion": "Confirm Deletion", "ConfirmEndPlayerSession": "Would you like to shutdown Jellyfin on {0}?", "Connect": "Connect", @@ -191,6 +192,7 @@ "DeleteDevicesConfirmation": "Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in.", "DeleteImage": "Delete Image", "DeleteImageConfirmation": "Are you sure you wish to delete this image?", + "DeleteLyrics": "Delete lyrics", "DeleteMedia": "Delete media", "DeleteSeries": "Delete Series", "DeleteEpisode": "Delete Episode", @@ -277,6 +279,7 @@ "ErrorAddingXmlTvFile": "There was an error accessing the XMLTV file. Please ensure the file exists and try again.", "ErrorDefault": "There was an error processing the request. Please try again later.", "ErrorDeletingItem": "There was an error deleting the item from the server. Please check that Jellyfin has write access to the media folder and try again.", + "ErrorDeletingLyrics": "There was an error deleting the lyrics from the server. Please check that Jellyfin has write access to the media folder and try again.", "ErrorGettingTvLineups": "There was an error downloading TV lineups. Please ensure your information is correct and try again.", "ErrorPlayerNotFound": "No player found for the requested media.", "ErrorPleaseSelectLineup": "Please select a lineup and try again. If no lineups are available, then please check that your username, password, and postal code is correct.", @@ -371,6 +374,7 @@ "HeaderDeleteItem": "Delete Item", "HeaderDeleteSeries": "Delete Series", "HeaderDeleteItems": "Delete Items", + "HeaderDeleteLyrics": "Delete Lyrics", "HeaderDeleteProvider": "Delete Provider", "HeaderDeleteTaskTrigger": "Delete Task Trigger", "HeaderDetectMyDevices": "Detect My Devices", @@ -428,6 +432,7 @@ "HeaderNewRepository": "New Repository", "HeaderNextEpisodePlayingInValue": "Next Episode Playing in {0}", "HeaderNextVideoPlayingInValue": "Next Video Playing in {0}", + "HeaderNoLyrics": "No lyrics found", "HeaderOnNow": "On Now", "HeaderOtherItems": "Other Items", "HeaderParentalRatings": "Parental Ratings", @@ -962,6 +967,7 @@ "LogoScreensaver": "Logo Screensaver", "Lyric": "Lyric", "Lyricist": "Lyricist", + "Lyrics": "Lyrics", "ManageLibrary": "Manage library", "ManageRecording": "Manage recording", "MapChannels": "Map Channels", @@ -1522,6 +1528,7 @@ "VideoAudio": "Video Audio", "ViewAlbum": "View album", "ViewAlbumArtist": "View album artist", + "ViewLyrics": "View lyrics", "ViewPlaybackInfo": "View playback info", "Watched": "Watched", "Wednesday": "Wednesday", diff --git a/src/styles/lyrics.scss b/src/styles/lyrics.scss new file mode 100644 index 00000000000..8642dd05b13 --- /dev/null +++ b/src/styles/lyrics.scss @@ -0,0 +1,31 @@ +.lyricPage { + padding-top: 4.2em !important; + display: flex; + justify-content: center; +} + +.dynamicLyricsContainer { + display: flex; + flex-direction: column; +} + +.lyricsLine { + display: inline-block; + width: fit-content; + margin: 0.1em; + font-size: 30px; + color: inherit; + min-height: 2em; +} + +.futureLyric { + opacity: 0.3; +} + +.pastLyric { + opacity: 0.7; +} + +.dynamicLyric { + cursor: pointer; +}