/* SPDX-FileCopyrightText: 2016 (c) Matthieu Gallien SPDX-FileCopyrightText: 2019 (c) Nate Graham SPDX-License-Identifier: LGPL-3.0-or-later */ import QtQuick 2.15 import QtQuick.Window 2.2 import QtQuick.Controls 2.15 import QtQml.Models 2.2 import QtQuick.Layouts 1.2 import QtQuick.Effects as FX import org.kde.kirigami 2.12 as Kirigami import org.kde.elisa Kirigami.Page { id: topItem property int databaseId: 0 property var trackType property string songTitle: "" property string albumName: "" property string artistName: "" property url albumArtUrl: "" property url fileUrl: "" property int albumId property string albumArtist: "" signal openArtist() signal openAlbum() readonly property bool nothingPlaying: albumName.length === 0 && artistName.length === 0 && albumArtUrl.toString().length === 0 && songTitle.length === 0 && fileUrl.toString().length === 0 title: i18nc("@title:window Title of the context view related to the currently playing track", "Now Playing") padding: 0 property bool isWidescreen: mainWindow.width >= elisaTheme.viewSelectorSmallSizeThreshold onAlbumArtUrlChanged: { background.loadImage(); } TrackContextMetaDataModel { id: metaDataModel onLyricsChanged: lyricsModel.setLyric(lyrics) manager: ElisaApplication.musicManager } // Header with title and actions globalToolBarStyle: Kirigami.ApplicationHeaderStyle.None header: ToolBar { // Override color to use standard window colors, not header colors // TODO: remove this if the HeaderBar component is ever removed or moved // to the bottom of the window such that this toolbar touches the window // titlebar Kirigami.Theme.colorSet: Kirigami.Theme.Window RowLayout { anchors.fill: parent spacing: Kirigami.Units.smallSpacing FlatButtonWithToolTip { id: showSidebarButton objectName: 'showSidebarButton' visible: Kirigami.Settings.isMobile text: i18nc("@action:button", "Open sidebar") icon.name: "open-menu-symbolic" onClicked: mainWindow.globalDrawer.open() } Kirigami.Heading { Layout.fillWidth: true Layout.leftMargin: Kirigami.Units.largeSpacing Layout.alignment: Qt.AlignVCenter text: topItem.title } // Invisible; this exists purely to make the toolbar height match that // of the adjacent one Item { implicitHeight: elisaTheme.toolBarHeaderMinimumHeight } ButtonGroup { id: nowPlayingButtons onCheckedButtonChanged: { persistentSettings.nowPlayingPreferLyric = nowPlayingButtons.checkedButton === showLyricButton } } FlatButtonWithToolTip { id: showMetaDataButton ButtonGroup.group: nowPlayingButtons readonly property alias item: allMetaDataScroll checkable: true checked: !persistentSettings.nowPlayingPreferLyric display: topItem.isWidescreen ? AbstractButton.TextBesideIcon : AbstractButton.IconOnly icon.name: "documentinfo" text: i18nc("@option:radio One of the 'now playing' views", "Metadata") visible: !contentLayout.wideMode } FlatButtonWithToolTip { id: showLyricButton ButtonGroup.group: nowPlayingButtons checkable: true checked: persistentSettings.nowPlayingPreferLyric display: topItem.isWidescreen ? AbstractButton.TextBesideIcon : AbstractButton.IconOnly icon.name: "view-media-lyrics" text: i18nc("@option:radio One of the 'now playing' views", "Lyrics") visible: !contentLayout.wideMode } FlatButtonWithToolTip { id: showPlaylistButton visible: Kirigami.Settings.isMobile text: i18nc("@action:button", "Show Playlist") icon.name: "view-media-playlist" display: topItem.isWidescreen ? AbstractButton.TextBesideIcon : AbstractButton.IconOnly onClicked: { if (topItem.isWidescreen) { contentView.showPlaylist = !contentView.showPlaylist; } else { playlistDrawer.open(); } } } } } Item { anchors.fill: parent // Blurred album art background StackView { id: background anchors.fill: parent readonly property bool active: ElisaApplication.showNowPlayingBackground && !topItem.nothingPlaying property Item pendingImage property bool doesSkipAnimation: true layer.enabled: true opacity: 0.2 layer.effect: FX.MultiEffect { autoPaddingEnabled: false blurEnabled: true blurMax: 40 blur: 1 } replaceEnter: Transition { OpacityAnimator { id: replaceEnterOpacityAnimator from: 0 to: 1 // 1 is HACK for https://bugreports.qt.io/browse/QTBUG-106797 to avoid flickering duration: background.doesSkipAnimation ? 1 : Kirigami.Units.longDuration } } // Keep the old image around till the new one is fully faded in // If we fade both at the same time you can see the background behind glimpse through replaceExit: Transition { PauseAnimation { duration: replaceEnterOpacityAnimator.duration } } onActiveChanged: loadImage() function loadImage() { if (pendingImage) { pendingImage.statusChanged.disconnect(replaceWhenLoaded); pendingImage.destroy(); pendingImage = null; } if (!active) { clear(); return; } doesSkipAnimation = currentItem == undefined; pendingImage = backgroundComponent.createObject(background, { "source": topItem.albumArtUrl.toString() === "" ? Qt.resolvedUrl(elisaTheme.defaultAlbumImage) : topItem.albumArtUrl, "opacity": 0, }); if (pendingImage.status === Image.Loading) { pendingImage.statusChanged.connect(background.replaceWhenLoaded); } else { background.replaceWhenLoaded(); } } function replaceWhenLoaded() { pendingImage.statusChanged.disconnect(replaceWhenLoaded); replace(pendingImage, {}, StackView.Transition); pendingImage = null; } Component.onCompleted: { loadImage(); } } Component { id: backgroundComponent Image { asynchronous: true fillMode: Image.PreserveAspectCrop // HACK: set sourceSize to a fixed value to prevent background flickering (BUG431607) onStatusChanged: { if (status === Image.Ready && (sourceSize.width > Kirigami.Units.gridUnit * 50 || sourceSize.height > Kirigami.Units.gridUnit * 50)) { sourceSize = Qt.size(Kirigami.Units.gridUnit * 50, Kirigami.Units.gridUnit * 50); } } StackView.onRemoved: { destroy(); } } } RowLayout { id: contentLayout property bool wideMode: allMetaDataLoader.width <= width * 0.5 && allMetaDataLoader.height <= height anchors.fill: parent visible: !topItem.nothingPlaying spacing: 0 // Metadata ScrollView { id: allMetaDataScroll implicitWidth: { if (contentLayout.wideMode) { return contentLayout.width * 0.5 } else { return showMetaDataButton.checked ? contentLayout.width : 0 } } implicitHeight: Math.min(allMetaDataLoader.height, parent.height) contentWidth: availableWidth contentHeight: allMetaDataLoader.height // HACK: workaround for https://bugreports.qt.io/browse/QTBUG-83890 ScrollBar.horizontal.policy: ScrollBar.AlwaysOff Loader { id: allMetaDataLoader sourceComponent: Kirigami.FormLayout { id: allMetaData property real margins: Kirigami.Units.largeSpacing + allMetaDataScroll.ScrollBar.vertical.width width: (implicitWidth + margins <= contentLayout.width * 0.5 ? contentLayout.width * 0.5 : contentLayout.width) - margins x: wideMode? (allMetaDataScroll.width - width) * 0.5 : Kirigami.Units.largeSpacing Repeater { id: trackData model: metaDataModel delegate: Item { Kirigami.FormData.label: "" + model.name + ":" implicitWidth: childrenRect.width implicitHeight: childrenRect.height MediaTrackMetadataDelegate { maximumWidth: contentLayout.width - allMetaData.margins index: model.index name: model.name display: model.display type: model.type readOnly: true url: topItem.fileUrl } } } } // We need unload Kirigami.FormLayout and recreate it // to avoid lots of warnings in the terminal Timer { id: resetTimer interval: 0 onTriggered: { allMetaDataLoader.active = true } } Connections { target: metaDataModel function onModelAboutToBeReset() { allMetaDataLoader.active = false } function onModelReset() { resetTimer.restart() } } } } // Lyrics Item { id: lyricItem Layout.fillHeight: true implicitWidth: { if (contentLayout.wideMode) { return contentLayout.width * 0.5 } else { return showLyricButton.checked ? contentLayout.width : 0 } } ScrollView { id: lyricScroll anchors.centerIn: parent height: Math.min(lyricItem.height, implicitHeight) width: lyricItem.width // HACK: workaround for https://bugreports.qt.io/browse/QTBUG-83890 ScrollBar.horizontal.policy: ScrollBar.AlwaysOff PropertyAnimation { id: lyricScrollAnimation // the target is a flickable target: lyricScroll.contentItem property: "contentY" onToChanged: restart() } contentItem: ListView { id: lyricsView model: lyricsModel delegate: Label { text: lyric width: lyricsView.width wrapMode: Text.WordWrap font.bold: ListView.isCurrentItem horizontalAlignment: contentLayout.wideMode? Text.AlignLeft : Text.AlignHCenter MouseArea { height: parent.height width: Math.min(parent.width, parent.contentWidth) x: contentLayout.wideMode ? 0 : (parent.width - width) / 2 enabled: lyricsModel.isLRC cursorShape: enabled ? Qt.PointingHandCursor : undefined onClicked: { ElisaApplication.audioPlayer.position = timestamp; } } } currentIndex: lyricsModel.highlightedIndex onCurrentIndexChanged: { if (currentIndex === -1) return // center aligned var toPos = Math.round(currentItem.y + currentItem.height * 0.5 - lyricScroll.height * 0.5) // make sure the first and the last lines are always // positioned at the beginning and the end of the view toPos = Math.max(toPos, 0) toPos = Math.min(toPos, contentHeight - lyricScroll.height) lyricScrollAnimation.to = toPos } } } LyricsModel { id: lyricsModel } Connections { target: ElisaApplication.audioPlayer function onPositionChanged(position) { lyricsModel.setPosition(position) } } Loader { id: lyricPlaceholder anchors.centerIn: parent width: parent.width active: lyricsView.count === 0 visible: active && status === Loader.Ready sourceComponent: Kirigami.PlaceholderMessage { text: i18nc("@info:placeholder", "No lyrics found") icon.name: "view-media-lyrics" } } } } // "Nothing Playing" message Loader { anchors.centerIn: parent width: parent.width - (Kirigami.Units.largeSpacing * 4) active: topItem.nothingPlaying visible: active && status === Loader.Ready sourceComponent: Kirigami.PlaceholderMessage { text: i18nc("@info:placeholder", "Nothing playing") icon.name: "view-media-track" } } } // Footer with file path label footer: ToolBar { implicitHeight: Math.round(Kirigami.Units.gridUnit * 2) visible: !topItem.nothingPlaying RowLayout { anchors.fill: parent spacing: Kirigami.Units.smallSpacing LabelWithToolTip { id: fileUrlLabel text: metaDataModel.fileUrl elide: Text.ElideLeft Layout.fillWidth: true } Kirigami.ActionToolBar { // because fillWidth is true by default Layout.fillWidth: false // when there is not enough space, show the button in the compact mode // then the file url will be elided if needed Layout.preferredWidth: parent.width > fileUrlLabel.implicitWidth + spacing + maximumContentWidth ? maximumContentWidth : Kirigami.Units.gridUnit * 2 Layout.fillHeight: true actions: [ Kirigami.Action { text: i18nc("@action:button", "Show In Folder") icon.name: 'document-open-folder' visible: metaDataModel.fileUrl.toString() !== "" && !metaDataModel.fileUrl.toString().startsWith("http") && !metaDataModel.fileUrl.toString().startsWith("rtsp") onTriggered: { ElisaApplication.showInFolder(metaDataModel.fileUrl) } } ] } } } onFileUrlChanged: { if (ElisaApplication.musicManager && trackType !== undefined && fileUrl.toString().length !== 0) { if (databaseId !== 0) { metaDataModel.initializeByIdAndUrl(trackType, databaseId, fileUrl) } else { metaDataModel.initializeByUrl(trackType, fileUrl) } } } onTrackTypeChanged: { if (ElisaApplication.musicManager && trackType !== undefined && fileUrl.toString().length !== 0) { if (databaseId !== 0) { metaDataModel.initializeByIdAndUrl(trackType, databaseId, fileUrl) } else { metaDataModel.initializeByUrl(trackType, fileUrl) } } } Connections { target: ElisaApplication function onMusicManagerChanged() { if (ElisaApplication.musicManager && trackType !== undefined && databaseId !== 0) { metaDataModel.initializeByIdAndUrl(trackType, databaseId, fileUrl) } } } Component.onCompleted: { if (ElisaApplication.musicManager && trackType !== undefined) { if (databaseId !== 0) { metaDataModel.initializeByIdAndUrl(trackType, databaseId, fileUrl) } else { metaDataModel.initializeByUrl(trackType, fileUrl) } } } }