Compare commits

...
Author SHA1 Message Date
Jonah Brüchert e3af29f50a Make database query result deserialization more automatic 2022-05-23 13:12:23 +02:00
Devin Lin abafaee3d9 Add widescreen minimized progress bar 2022-05-22 21:57:15 -04:00
Devin Lin 81d5096ae7 Fix 2022-05-22 21:49:14 -04:00
Devin Lin bf30c5d4bc Shrink media controls slightly 2022-05-22 21:41:23 -04:00
Devin Lin a7ea6c49c7 Remove debug message 2022-05-22 21:31:53 -04:00
Devin Lin b078d1ab57 Revamp media player UI 2022-05-22 21:26:19 -04:00
13 changed files with 1111 additions and 300 deletions

No files matched your search

+13 -13
View File
@@ -70,10 +70,10 @@ const QString DATABASE_TYPE_SQLITE = QStringLiteral("QSQLITE");
/// Parse the row into a tuple of the given types.
/// The types need to be deserializable from QVariant.
///
template <typename ...RowTypes>
auto parseRow(const Row &row) -> std::tuple<RowTypes...>
template <typename RowTypesTuple>
auto parseRow(const Row &row) -> RowTypesTuple
{
auto tuple = std::tuple<RowTypes...>();
auto tuple = RowTypesTuple();
int i = 0;
asyncdatabase_private::iterate_tuple(tuple, [&](auto &elem) {
elem = row.at(i).value<std::decay_t<decltype(elem)>>();
@@ -86,12 +86,12 @@ auto parseRow(const Row &row) -> std::tuple<RowTypes...>
/// Parse the rows into a list of tuples of the given types.
/// The types need to be deserializable from QVariant.
///
template <typename ...RowTypes>
auto parseRows(const Rows &rows) -> std::vector<std::tuple<RowTypes...>> {
std::vector<std::tuple<RowTypes...>> parsedRows;
template <typename RowTypesTuple>
auto parseRows(const Rows &rows) -> std::vector<RowTypesTuple> {
std::vector<RowTypesTuple> parsedRows;
parsedRows.reserve(rows.size());
for (const auto &row : rows) {
parsedRows.push_back(parseRow<RowTypes...>(row));
parsedRows.push_back(parseRow<RowTypesTuple>(row));
}
return parsedRows;
@@ -138,17 +138,17 @@ public:
/// \param parameters to bind to the placeholders in the SQL query.
/// \return Future of a list of lists of variants.
///
template <typename ...Args>
auto getResults(const QString &sqlQuery, Args... args) -> QFuture<Rows> {
return db().getResults(sqlQuery, args...);
template <typename T, typename ...Args>
auto getResults(const QString &sqlQuery, Args... args) -> QFuture<std::vector<T>> {
return db().getResults<T, Args...>(sqlQuery, args...);
}
///
/// \brief Like getResults, but for retrieving just one row.
///
template <typename ...Args>
auto getResult(const QString &sqlQuery, Args... args) -> QFuture<std::optional<Row>> {
return db().getResult(sqlQuery, args...);
template <typename T, typename ...Args>
auto getResult(const QString &sqlQuery, Args... args) -> QFuture<std::optional<T>> {
return db().getResult<T, Args...>(sqlQuery, args...);
}
ThreadedDatabase();
+19 -8
View File
@@ -76,19 +76,30 @@ class AsyncSqlDatabase : public QObject {
public:
QFuture<void> establishConnection(const DatabaseConfiguration &configuration);
template <typename ...Args>
auto getResults(const QString &sqlQuery, Args... args) -> QFuture<Rows> {
return runAsync<Rows>([=, this] {
template <typename T, typename ...Args>
auto getResults(const QString &sqlQuery, Args... args) -> QFuture<std::vector<T>> {
return runAsync<std::vector<T>>([=, this] {
auto query = executeQuery(sqlQuery, args...);
return retrieveRows(query);
auto dbrows = retrieveRows(query);
const auto rows = parseRows<typename T::ColumnTypes>(dbrows);
std::vector<T> deserializedRows;
for (auto row : rows) {
deserializedRows.push_back(T::fromSql(std::move(row)));
}
return deserializedRows;
});
}
template <typename ...Args>
auto getResult(const QString &sqlQuery, Args... args) -> QFuture<std::optional<Row>> {
return runAsync<std::optional<Row>>([=, this] {
template <typename T, typename ...Args>
auto getResult(const QString &sqlQuery, Args... args) -> QFuture<std::optional<T>> {
return runAsync<std::optional<T>>([=, this]() -> std::optional<T> {
auto query = executeQuery(sqlQuery, args...);
return retrieveOptionalRow(query);
if (const auto row = retrieveOptionalRow(query)) {
return T::fromSql(parseRow<typename T::ColumnTypes>(*row));
}
return std::nullopt;
});
}
+427
View File
@@ -0,0 +1,427 @@
// SPDX-FileCopyrightText: 2020-2022 Devin Lin <[email protected]>
//
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtGraphicalEffects 1.0
import QtMultimedia 5.12
import org.kde.kirigami 2.19 as Kirigami
import org.kde.ytmusic 1.0
Item {
id: root
required property var info // VideoInfoExtractor object
required property var audio // Audio object
signal requestClose()
// background image
Item {
anchors.fill: parent
Rectangle {
anchors.fill: parent
color: Qt.rgba(25, 25, 30, 1)
}
Image {
anchors.fill: parent
asynchronous: true
source: info.thumbnail
fillMode: Image.PreserveAspectCrop
sourceSize.width: 512
sourceSize.height: 512
}
layer.enabled: true
layer.effect: HueSaturation {
cached: true
lightness: -0.5
saturation: 1.9
layer.enabled: true
layer.effect: FastBlur {
cached: true
radius: 100
}
}
}
// content
ColumnLayout {
anchors.fill: parent
spacing: 0
// hide arrow button
ToolButton {
id: closeButton
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
Layout.maximumHeight: parent.height
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.maximumWidth: parent.height
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
Layout.topMargin: Kirigami.Units.smallSpacing
icon.name: "arrow-down"
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
onClicked: root.requestClose()
}
// top tab bar
RowLayout {
id: tabBar
Layout.alignment: Qt.AlignHCenter
ToolButton {
id: nowPlayingTab
text: i18n("Now Playing")
padding: Kirigami.Units.largeSpacing
onClicked: swipeView.setCurrentIndex(0)
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
Layout.preferredWidth: Kirigami.Units.gridUnit * 9
contentItem: Label {
text: nowPlayingTab.text
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
color: "white" // we need to ensure the label is white
}
// selection indicator
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
color: Kirigami.Theme.highlightColor
height: 1
visible: swipeView.currentIndex == 0
}
}
ToolButton {
id: queueTab
text: i18n("Queue")
padding: Kirigami.Units.largeSpacing
onClicked: swipeView.setCurrentIndex(1)
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
Layout.preferredWidth: Kirigami.Units.gridUnit * 9
contentItem: Label {
text: queueTab.text
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
color: "white"
}
// selection indicator
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
color: Kirigami.Theme.highlightColor
height: 1
visible: swipeView.currentIndex == 1
}
}
}
// tabs
SwipeView {
id: swipeView
property double specWidth: {
let allowedWidth = root.width - Kirigami.Units.largeSpacing * 4;
let allowedHeight = root.height - Kirigami.Units.largeSpacing * 8 - (closeButton.height + tabBar.height + bottomPlayerControls.height);
if (allowedWidth > allowedHeight) {
return allowedHeight;
} else {
return allowedWidth;
}
}
Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
Layout.fillWidth: true
Layout.preferredHeight: specWidth
// music art
Item {
height: swipeView.width
width: swipeView.height
Image {
anchors.centerIn: parent
width: swipeView.specWidth
height: swipeView.specWidth
asynchronous: true
mipmap: true
source: info.thumbnail
sourceSize {
width: 512
height: 512
}
fillMode: Image.PreserveAspectFit
}
}
// playlist
ListView {
id: playlistView
width: swipeView.width
height: swipeView.height
clip: true
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
BusyIndicator {
anchors.centerIn: parent
visible: UserPlaylistModel.loading || UserPlaylistModel.loading
}
model: UserPlaylistModel
delegate: Kirigami.SwipeListItem {
id: delegateItem
required property string title
required property string videoId
required property string artists
required property bool isCurrent
backgroundColor: 'transparent'
highlighted: isCurrent
onClicked: UserPlaylistModel.skipTo(videoId)
ColumnLayout {
Layout.fillWidth: true
Kirigami.Heading {
elide: Text.ElideRight
Layout.fillWidth: true
level: 2
text: title
}
Label {
elide: Text.ElideRight
Layout.fillWidth: true
text: artists
}
}
actions: [
Kirigami.Action {
text: i18n("Remove Track")
icon.name: "list-remove"
icon.color: "white"
onTriggered: UserPlaylistModel.remove(delegateItem.videoId)
}
]
}
}
}
ColumnLayout {
id: bottomPlayerControls
Layout.topMargin: Kirigami.Units.largeSpacing
Layout.leftMargin: Kirigami.Units.gridUnit * 2
Layout.rightMargin: Kirigami.Units.gridUnit * 2
Layout.bottomMargin: Kirigami.Units.gridUnit * 2
// song name
Label {
id: mainLabel
text: info.title ? info.title : i18n("No media playing")
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 1
// Hardcoded because the footerbar blur always makes a dark-ish
// background, so we don't want to use a color scheme color that
// might also be dark
color: "white"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.5
font.weight: Font.Bold
font.bold: true
}
// song artist
Kirigami.Heading {
id: authorLabel
text: info.artist ? info.artist : info.channel
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 1
// Hardcoded because the footerbar blur always makes a dark-ish
// background, so we don't want to use a color scheme color that
// might also be dark
color: "white"
opacity: 0.9
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.3
font.weight: Font.Bold
font.bold: true
}
// slider row
RowLayout {
Layout.topMargin: Kirigami.Units.gridUnit * 2
spacing: Kirigami.Units.smallSpacing
Label {
Layout.alignment: Qt.AlignVCenter
color: "white"
visible: info.title
text: PlayerUtils.formatTimestamp(audio.position)
}
Slider {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
from: 0
to: audio.duration
value: audio.position
enabled: audio.seekable
onMoved: {
console.log("Value:", value);
audio.seek(Math.floor(value));
}
Behavior on value {
NumberAnimation {
duration: 1000
}
}
}
Label {
Layout.alignment: Qt.AlignVCenter
color: "white"
visible: info.title
text: PlayerUtils.formatTimestamp(audio.duration)
}
}
RowLayout {
Layout.topMargin: Kirigami.Units.largeSpacing
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
// ensure white icons
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
ToolButton {
id: favouriteButton
readonly property QtObject favouriteWatcher: Library.favouriteWatcher(info.videoId)
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5
Layout.maximumWidth: height
Layout.preferredWidth: height
onClicked: if (favouriteWatcher)
favouriteWatcher.isFavourite
? Library.removeFavourite(info.videoId)
: Library.addFavourite(info.videoId, info.title)
icon.name: favouriteWatcher ? (favouriteWatcher.isFavourite ? "starred-symbolic" : "non-starred-symbolic") : ""
icon.width: Kirigami.Units.gridUnit * 1.5
icon.height: Kirigami.Units.gridUnit * 1.5
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
}
ToolButton {
id: skipBackwardButton
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5
Layout.maximumWidth: height
Layout.preferredWidth: height
enabled: false
icon.name: "media-skip-backward"
icon.width: Kirigami.Units.gridUnit * 1.5
icon.height: Kirigami.Units.gridUnit * 1.5
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
}
ToolButton {
id: playPauseButton
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
Layout.preferredHeight: Kirigami.Units.gridUnit * 4
Layout.maximumWidth: height
Layout.preferredWidth: height
enabled: info.audioUrl != ""
onClicked: audio.playbackState === Audio.PlayingState ? audio.pause() : audio.play()
icon.name: audio.playbackState === Audio.PlayingState ? "media-playback-pause" : "media-playback-start"
icon.width: Kirigami.Units.gridUnit * 3
icon.height: Kirigami.Units.gridUnit * 3
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
}
ToolButton {
id: skipForwardButton
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5
Layout.maximumWidth: height
Layout.preferredWidth: height
enabled: UserPlaylistModel.canSkip
onClicked: UserPlaylistModel.next()
icon.name: "media-skip-forward"
icon.width: Kirigami.Units.gridUnit * 1.5
icon.height: Kirigami.Units.gridUnit * 1.5
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
}
ToolButton {
id: shuffleButton
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5
Layout.maximumWidth: height
Layout.preferredWidth: height
onClicked: UserPlaylistModel.shufflePlaylist()
icon.name: "media-playlist-shuffle"
icon.width: Kirigami.Units.gridUnit * 1.5
icon.height: Kirigami.Units.gridUnit * 1.5
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
}
}
}
}
}
+207
View File
@@ -0,0 +1,207 @@
// SPDX-FileCopyrightText: 2020-2022 Devin Lin <[email protected]>
//
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtGraphicalEffects 1.0
import QtMultimedia 5.12
import org.kde.kirigami 2.19 as Kirigami
import org.kde.ytmusic 1.0
Item {
id: root
required property int progressBarHeight
required property int minimizedPlayerContentHeight
required property var info // VideoInfoExtractor object
required property var audio // Audio object
readonly property bool isWidescreen: width >= Kirigami.Units.gridUnit * 40
signal requestOpen()
Rectangle {
id: miniProgressBar
z: 1
visible: root.isWidescreen
anchors.top: parent.top
anchors.left: parent.left
height: root.progressBarHeight
color: Kirigami.Theme.highlightColor
width: parent.width * (audio.position / audio.duration)
}
RowLayout {
anchors.fill: parent
spacing: 0
Rectangle {
Layout.fillHeight: true
Layout.fillWidth: true
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
color: Qt.rgba(0, 0, 0, trackClick.containsMouse ? 0.1 : trackClick.pressed ? 0.3 : 0)
RowLayout {
anchors.fill: parent
anchors.leftMargin: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
// track image
Item {
property double imageSize: root.height - Kirigami.Units.largeSpacing * 2
Layout.alignment: Qt.AlignVCenter
Layout.maximumWidth: imageSize
Layout.preferredWidth: imageSize
Layout.maximumHeight: imageSize
Layout.minimumHeight: imageSize
Image {
id: mainIcon
anchors.fill: parent
asynchronous: true
mipmap: true
source: info.thumbnail
sourceSize.width: 512
sourceSize.height: 512
fillMode: Image.PreserveAspectFit
}
}
// track information
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Label {
id: mainLabel
text: info.title ? info.title : i18n("No media playing")
textFormat: Text.PlainText
wrapMode: Text.Wrap
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
maximumLineCount: 1
// Hardcoded because the footerbar blur always makes a dark-ish
// background, so we don't want to use a color scheme color that
// might also be dark
color: "white"
font.weight: Font.Bold
}
Label {
id: authorLabel
text: info.artist ? info.artist : info.channel
textFormat: Text.PlainText
wrapMode: Text.Wrap
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
maximumLineCount: 1
// Hardcoded because the footerbar blur always makes a dark-ish
// background, so we don't want to use a color scheme color that
// might also be dark
color: "white"
}
}
}
MouseArea {
id: trackClick
anchors.fill: parent
hoverEnabled: true
onClicked: root.requestOpen()
}
}
RowLayout {
visible: root.isWidescreen
Layout.preferredWidth: 0
Layout.fillHeight: true
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.smallSpacing
Label {
Layout.alignment: Qt.AlignVCenter
color: "white"
visible: info.title
text: PlayerUtils.formatTimestamp(audio.position)
}
Slider {
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
from: 0
to: audio.duration
value: audio.position
enabled: audio.seekable
onMoved: {
console.log("Value:", value);
audio.seek(Math.floor(value));
}
Behavior on value {
NumberAnimation {
duration: 1000
}
}
}
Label {
Layout.alignment: Qt.AlignVCenter
color: "white"
visible: info.title
text: PlayerUtils.formatTimestamp(audio.duration)
}
}
ToolButton {
id: playPauseButton
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.maximumWidth: height
Layout.preferredWidth: height
enabled: info.audioUrl != ""
onClicked: audio.playbackState === Audio.PlayingState ? audio.pause() : audio.play()
icon.name: audio.playbackState === Audio.PlayingState ? "media-playback-pause" : "media-playback-start"
icon.width: Kirigami.Units.gridUnit
icon.height: Kirigami.Units.gridUnit
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
}
ToolButton {
id: skipForwardButton
Layout.rightMargin: Math.floor(Kirigami.Units.smallSpacing / 2)
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.maximumWidth: height
Layout.preferredWidth: height
enabled: UserPlaylistModel.canSkip
onClicked: UserPlaylistModel.next()
icon.name: "media-skip-forward"
icon.width: Kirigami.Units.gridUnit
icon.height: Kirigami.Units.gridUnit
icon.color: "white"
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
Kirigami.Theme.inherit: false
}
}
}
+366 -224
View File
@@ -1,280 +1,422 @@
// SPDX-FileCopyrightText: 2021 Jonah Brüchert <[email protected]>
// SPDX-FileCopyrightText: 2021 Bart De Vries <[email protected]>
// SPDX-FileCopyrightText: 2020-2022 Devin Lin <[email protected]>
//
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.7
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.12 as Controls
import QtMultimedia 5.12
import QtGraphicalEffects 1.0
import org.kde.kirigami 2.12 as Kirigami
import org.kde.ytmusic 1.0
import QtMultimedia 5.12
Item {
Flickable {
id: footerItem
property bool maximized: false
readonly property int progressBarHeight: Kirigami.Units.gridUnit / 6
readonly property int minimizedPlayerContentHeight: Math.round(Kirigami.Units.gridUnit * 3.5)
readonly property int minimizedPlayerHeight: minimizedPlayerContentHeight + progressBarHeight
anchors.left: parent.left
anchors.right: parent.right
height: maximized ? applicationWindow().height : 75
Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: Kirigami.Theme.Window
anchors.fill: footerLayout
boundsBehavior: Flickable.StopAtBounds
function close() {
toClose.restart();
}
function resetToBoundsOnFlick() {
if (!atYBeginning || !atYEnd) {
if (footerItem.verticalVelocity > 0) {
toOpen.restart();
} else if (footerItem.verticalVelocity < 0) {
toClose.restart();
} else { // i.e. when verticalVelocity === 0
if (contentY > contentHeight / 4) {
toOpen.restart();
} else {
toClose.restart();
}
}
}
}
function resetToBoundsOnResize() {
if (contentY > contentHeight / 4) {
contentY = contentHeight / 2;
} else {
contentY = 0;
}
}
NumberAnimation on contentY {
id: toOpen
from: contentY
to: contentHeight / 2
duration: Kirigami.Units.longDuration * 2
easing.type: Easing.OutCubic
running: false
}
NumberAnimation on contentY {
id: toClose
from: contentY
to: 0
duration: Kirigami.Units.longDuration * 2
easing.type: Easing.OutCubic
running: false
}
// snap to end
MouseArea {
anchors.fill: footerLayout
propagateComposedEvents: true
onPressed: {
toOpen.stop();
toClose.stop();
propagateComposedEvents = true;
}
onReleased: footerItem.resetToBoundsOnFlick()
}
onMovementStarted: {
toOpen.stop();
toClose.stop();
}
onFlickStarted: resetToBoundsOnFlick()
onMovementEnded: resetToBoundsOnFlick()
onHeightChanged: resetToBoundsOnResize()
property var videoInfoExtractor: VideoInfoExtractor {
id: info
onSongChanged: audio.play()
videoId: UserPlaylistModel.currentVideoId
onTitleChanged: Library.addPlaybackHistoryItem(info.videoId, info.title)
}
property var audioPlayer: Audio {
id: audio
source: info.audioUrl
onStatusChanged: {
if (status === Audio.EndOfMedia) {
console.log("Song ended");
UserPlaylistModel.next();
}
}
}
contentHeight: height * 2
ColumnLayout {
id: footerLayout
height: parent.height
anchors.fill: parent
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
GridLayout {
id: playerLayout
readonly property bool mobile: width > height
Layout.fillHeight: footerItem.maximized
// margin from top
Item {
Layout.minimumHeight: footerItem.height - footerItem.minimizedPlayerHeight
}
Controls.Control {
Layout.fillWidth: true
flow: width > height ? GridLayout.LeftToRight : GridLayout.TopToBottom
visible: footerItem.maximized
ColumnLayout {
Layout.preferredWidth: footerLayout.width * (playerLayout.mobile ? 1 : 0.5)
Layout.preferredHeight: footerLayout.height * (playerLayout.mobile ? 0.5 : 1)
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Image {
anchors.fill: parent
source: info.thumbnail
fillMode: Image.PreserveAspectFit
}
}
Controls.RoundButton {
readonly property QtObject favouriteWatcher: Library.favouriteWatcher(info.videoId)
Layout.alignment: Qt.AlignHCenter
icon.name: favouriteWatcher ? (favouriteWatcher.isFavourite ? "starred-symbolic" : "non-starred-symbolic") : ""
onClicked: if (favouriteWatcher)
favouriteWatcher.isFavourite
? Library.removeFavourite(info.videoId)
: Library.addFavourite(info.videoId, info.title)
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.preferredWidth: footerLayout.width * (playerLayout.mobile ? 1 : 0.5)
Layout.preferredHeight: footerLayout.height * (playerLayout.mobile ? 0.5 : 1)
Controls.ScrollView {
padding: 0
implicitHeight: footerItem.minimizedPlayerHeight
// minimized player background
background: Item {
Rectangle {
anchors.fill: parent
color: Qt.rgba(25, 25, 30, 1)
}
Image {
opacity: 0.2
source: info.thumbnail
asynchronous: true
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
}
layer.enabled: true
layer.effect: HueSaturation {
cached: true
ListView {
anchors.fill: parent
id: playlistView
lightness: -0.5
saturation: 1.9
clip: true
Controls.BusyIndicator {
anchors.centerIn: parent
visible: UserPlaylistModel.loading || UserPlaylistModel.loading
}
onCountChanged: {
if (count < 1) {
footerItem.maximized = false
}
}
model: UserPlaylistModel
delegate: Kirigami.SwipeListItem {
id: delegateItem
required property string title
required property string videoId
required property string artists
required property bool isCurrent
highlighted: isCurrent
onClicked: UserPlaylistModel.skipTo(videoId)
ColumnLayout {
Layout.fillWidth: true
Kirigami.Heading {
elide: Text.ElideRight
Layout.fillWidth: true
level: 2
text: title
}
Controls.Label {
elide: Text.ElideRight
Layout.fillWidth: true
text: artists
}
}
actions: [
Kirigami.Action {
text: i18n("Remove Track")
icon.name: "list-remove"
onTriggered: UserPlaylistModel.remove(delegateItem.videoId)
}
]
}
header: Controls.ToolBar {
anchors.left: parent.left
anchors.right: parent.right
RowLayout {
anchors.fill: parent
Item {
Layout.fillWidth: true
}
Controls.ToolButton {
text: i18n("Clear")
icon.name: "edit-clear-all"
onClicked: UserPlaylistModel.clear()
}
Controls.ToolButton {
text: i18n("Shuffle")
icon.name: "media-playlist-shuffle"
onClicked: UserPlaylistModel.shufflePlaylist()
}
}
}
layer.enabled: true
layer.effect: FastBlur {
cached: true
radius: 64
transparentBorder: false
}
}
}
MinimizedPlayerControls {
progressBarHeight: footerItem.progressBarHeight
minimizedPlayerContentHeight: footerItem.minimizedPlayerContentHeight
height: minimizedPlayerHeight
focus: true
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
info: footerItem.videoInfoExtractor
audio: footerItem.audioPlayer
onRequestOpen: toOpen.start();
}
}
Kirigami.Separator {
MaximizedPlayerPage {
id: maximizedPlayerPage
Layout.fillWidth: true
Layout.fillHeight: true
implicitHeight: footerItem.height
info: footerItem.videoInfoExtractor
audio: footerItem.audioPlayer
onRequestClose: toClose.start();
}
//GridLayout {
//id: playerLayout
RowLayout {
id: player
//readonly property bool mobile: width > height
Layout.fillWidth: true
Layout.fillHeight: !footer.maximized
//Layout.fillHeight: footerItem.maximized
//Layout.fillWidth: true
//flow: width > height ? GridLayout.LeftToRight : GridLayout.TopToBottom
//visible: footerItem.maximized
VideoInfoExtractor {
id: info
//ColumnLayout {
//Layout.preferredWidth: footerLayout.width * (playerLayout.mobile ? 1 : 0.5)
//Layout.preferredHeight: footerLayout.height * (playerLayout.mobile ? 0.5 : 1)
onSongChanged: audio.play()
videoId: UserPlaylistModel.currentVideoId
onTitleChanged: Library.addPlaybackHistoryItem(info.videoId, info.title)
}
//Item {
//Layout.fillWidth: true
//Layout.fillHeight: true
Audio {
id: audio
//Image {
//anchors.fill: parent
//source: info.thumbnail
//fillMode: Image.PreserveAspectFit
//}
//}
//Controls.RoundButton {
//readonly property QtObject favouriteWatcher: Library.favouriteWatcher(info.videoId)
source: info.audioUrl
onStatusChanged: {
if (status === Audio.EndOfMedia) {
console.log("Song ended");
UserPlaylistModel.next();
}
}
}
//Layout.alignment: Qt.AlignHCenter
//icon.name: favouriteWatcher ? (favouriteWatcher.isFavourite ? "starred-symbolic" : "non-starred-symbolic") : ""
//onClicked: if (favouriteWatcher)
//favouriteWatcher.isFavourite
//? Library.removeFavourite(info.videoId)
//: Library.addFavourite(info.videoId, info.title)
//}
//}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
Layout.fillHeight: true
enabled: info.audioUrl != ""
text: audio.playbackState === Audio.PlayingState ? i18n("Pause") : i18n("Play")
visible: !info.loading
icon.name: audio.playbackState === Audio.PlayingState ? "media-playback-pause" : "media-playback-start"
onClicked: audio.playbackState === Audio.PlayingState ? audio.pause() : audio.play()
}
//Item {
//Layout.fillWidth: true
//Layout.fillHeight: true
//Layout.preferredWidth: footerLayout.width * (playerLayout.mobile ? 1 : 0.5)
//Layout.preferredHeight: footerLayout.height * (playerLayout.mobile ? 0.5 : 1)
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
Layout.fillHeight: true
enabled: UserPlaylistModel.canSkip
visible: !info.loading
icon.name: "media-skip-forward"
onClicked: UserPlaylistModel.next()
}
//Controls.ScrollView {
//anchors.fill: parent
Controls.BusyIndicator {
Layout.fillHeight: true
visible: info.loading
}
//ListView {
//anchors.fill: parent
//id: playlistView
ColumnLayout {
Layout.fillHeight: true
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
//clip: true
Kirigami.Heading {
level: 3
Layout.fillWidth: true
elide: Qt.ElideRight
text: info.title ? info.title : i18n("No media playing")
}
//Controls.BusyIndicator {
//anchors.centerIn: parent
//visible: UserPlaylistModel.loading || UserPlaylistModel.loading
//}
RowLayout{
//onCountChanged: {
//if (count < 1) {
//footerItem.maximized = false
//}
//}
Controls.Label{
visible: info.title
text: PlayerUtils.formatTimestamp(audio.position)
}
//model: UserPlaylistModel
Controls.Slider {
Layout.fillWidth: true
from: 0
to: audio.duration
value: audio.position
enabled: audio.seekable
onMoved: {
console.log("Value:", value);
audio.seek(Math.floor(value));
}
//delegate: Kirigami.SwipeListItem {
//id: delegateItem
//required property string title
//required property string videoId
//required property string artists
//required property bool isCurrent
Behavior on value {
NumberAnimation {
duration: 1000
}
//highlighted: isCurrent
//onClicked: UserPlaylistModel.skipTo(videoId)
}
//ColumnLayout {
//Layout.fillWidth: true
//Kirigami.Heading {
//elide: Text.ElideRight
//Layout.fillWidth: true
//level: 2
//text: title
//}
}
//Controls.Label {
//elide: Text.ElideRight
//Layout.fillWidth: true
//text: artists
//}
//}
Controls.Label{
visible: info.title
text: PlayerUtils.formatTimestamp(audio.duration)
}
//actions: [
//Kirigami.Action {
//text: i18n("Remove Track")
//icon.name: "list-remove"
//onTriggered: UserPlaylistModel.remove(delegateItem.videoId)
//}
//]
//}
}
//header: Controls.ToolBar {
//anchors.left: parent.left
//anchors.right: parent.right
//RowLayout {
//anchors.fill: parent
//Item {
//Layout.fillWidth: true
//}
//Controls.ToolButton {
//text: i18n("Clear")
//icon.name: "edit-clear-all"
//onClicked: UserPlaylistModel.clear()
//}
//Controls.ToolButton {
//text: i18n("Shuffle")
//icon.name: "media-playlist-shuffle"
//onClicked: UserPlaylistModel.shufflePlaylist()
//}
//}
//}
//}
//}
//}
//}
//Kirigami.Separator {
//Layout.fillWidth: true
//}
//RowLayout {
//id: player
//Layout.fillWidth: true
//Layout.fillHeight: !footer.maximized
//Controls.ToolButton {
//display: Controls.AbstractButton.IconOnly
//Layout.preferredWidth: parent.height
//Layout.fillHeight: true
//enabled: info.audioUrl != ""
//text: audio.playbackState === Audio.PlayingState ? i18n("Pause") : i18n("Play")
//visible: !info.loading
//icon.name: audio.playbackState === Audio.PlayingState ? "media-playback-pause" : "media-playback-start"
//onClicked: audio.playbackState === Audio.PlayingState ? audio.pause() : audio.play()
//}
//Controls.ToolButton {
//display: Controls.AbstractButton.IconOnly
//Layout.preferredWidth: parent.height
//Layout.fillHeight: true
//enabled: UserPlaylistModel.canSkip
//visible: !info.loading
//icon.name: "media-skip-forward"
//onClicked: UserPlaylistModel.next()
//}
//Controls.BusyIndicator {
//Layout.fillHeight: true
//visible: info.loading
//}
//ColumnLayout {
//Layout.fillHeight: true
//Layout.topMargin: Kirigami.Units.smallSpacing
//Layout.bottomMargin: Kirigami.Units.smallSpacing
//Kirigami.Heading {
//level: 3
//Layout.fillWidth: true
//elide: Qt.ElideRight
//text: info.title ? info.title : i18n("No media playing")
//}
//RowLayout{
//Controls.Label{
//visible: info.title
//text: PlayerUtils.formatTimestamp(audio.position)
//}
//Controls.Slider {
//Layout.fillWidth: true
//from: 0
//to: audio.duration
//value: audio.position
//enabled: audio.seekable
//onMoved: {
//console.log("Value:", value);
//audio.seek(Math.floor(value));
//}
//Behavior on value {
//NumberAnimation {
//duration: 1000
//}
//}
//}
//Controls.Label{
//visible: info.title
//text: PlayerUtils.formatTimestamp(audio.duration)
//}
//}
}
//}
Controls.ToolButton {
display: Controls.AbstractButton.IconOnly
Layout.preferredWidth: parent.height
text: i18n("Expand")
enabled: playlistView.count > 0
Layout.fillHeight: true
icon.name: footerItem.maximized ? "arrow-down" : "arrow-up"
onClicked: footerItem.maximized = !footerItem.maximized
}
//Controls.ToolButton {
//display: Controls.AbstractButton.IconOnly
//Layout.preferredWidth: parent.height
//text: i18n("Expand")
//enabled: playlistView.count > 0
//Layout.fillHeight: true
//icon.name: footerItem.maximized ? "arrow-down" : "arrow-up"
//onClicked: footerItem.maximized = !footerItem.maximized
//}
}
//}
}
Behavior on height {
NumberAnimation {
}
}
//Behavior on height {
//NumberAnimation {
//}
//}
}
+11 -4
View File
@@ -15,7 +15,6 @@ Kirigami.ApplicationWindow {
property alias searchField: searchField
header: Controls.Control {
visible: !footer.maximized
padding: Kirigami.Units.largeSpacing
background: Rectangle {
@@ -93,8 +92,6 @@ Kirigami.ApplicationWindow {
title: i18n("AudioTube")
controlsVisible: !footer.maximized
contextDrawer: Kirigami.ContextDrawer {
id: contextDrawer
}
@@ -122,6 +119,16 @@ Kirigami.ApplicationWindow {
showPassiveNotification(error)
}
}
pageStack.anchors.bottomMargin: playerFooter.minimizedPlayerHeight + 1
footer: PlayerFooter {}
// media player
PlayerFooter {
id: playerFooter
anchors.topMargin: -root.header.height
anchors.fill: parent
// only expand flicking area to full screen when it is open
z: (contentY === 0) ? -1 : 999
}
}
+9 -46
View File
@@ -40,16 +40,7 @@ Library &Library::instance()
FavouritesModel *Library::favourites()
{
auto future = mapFuture<Rows, std::vector<Song>>(
m_database->getResults("select * from favourites natural join songs"),
[](auto rows)
{
std::vector<Song> songs;
ranges::transform(parseRows<QString, QString, QString, QString>(rows),
std::back_inserter(songs),
Song::fromSql);
return songs;
});
auto future = m_database->getResults<Song>("select * from favourites natural join songs");
return new FavouritesModel(std::move(future), this);
}
@@ -75,16 +66,7 @@ FavouriteWatcher *Library::favouriteWatcher(const QString &videoId)
SearchHistoryModel *Library::searches()
{
return new SearchHistoryModel(mapFuture<Rows, std::vector<QString>>(
m_database->getResults("select (search_query) from searches order by search_id desc"),
[](const auto &rows)
{
std::vector<QString> out;
ranges::transform(rows, std::back_inserter(out), [](auto row) {
return row[0].toString();
});
return out;
}), this);
return new SearchHistoryModel(m_database->getResults<Search>("select (search_query) from searches order by search_id desc"), this);
}
void Library::addSearch(const QString &text)
@@ -94,16 +76,7 @@ void Library::addSearch(const QString &text)
PlaybackHistoryModel *Library::playbackHistory()
{
auto future = mapFuture<Rows, std::vector<PlayedSong>>(
m_database->getResults("select * from played_songs natural join songs"),
[](auto rows)
{
std::vector<PlayedSong> playedSongs;
ranges::transform(parseRows<QString, int, QString, QString, QString>(rows),
std::back_inserter(playedSongs),
PlayedSong::fromSql);
return playedSongs;
});
auto future = m_database->getResults<PlayedSong>("select * from played_songs natural join songs");
return new PlaybackHistoryModel(std::move(future), this);
}
@@ -118,16 +91,7 @@ void Library::addPlaybackHistoryItem(const QString &videoId, const QString &titl
PlaybackHistoryModel *Library::mostPlayed()
{
auto future = mapFuture<Rows, std::vector<PlayedSong>>(
m_database->getResults("select * from played_songs natural join songs order by plays desc limit 10"),
[](auto rows)
{
std::vector<PlayedSong> playedSongs;
ranges::transform(parseRows<QString, int, QString, QString, QString>(rows),
std::back_inserter(playedSongs),
PlayedSong::fromSql);
return playedSongs;
});
auto future = m_database->getResults<PlayedSong>("select * from played_songs natural join songs order by plays desc limit 10");
return new PlaybackHistoryModel(std::move(future), this);
}
@@ -247,10 +211,9 @@ FavouriteWatcher::FavouriteWatcher(Library *library, const QString &videoId)
: QObject(library), m_videoId(videoId), m_library(library)
{
auto update = [this] {
connectFuture(m_library->database().getResult("select count(*) from favourites where video_id = ?", m_videoId), this, [this](auto row) {
if (row) {
auto [favCount] = parseRow<int>(*row);
m_isFavourite = favCount > 0;
connectFuture(m_library->database().getResult<Count>("select count(*) from favourites where video_id = ?", m_videoId), this, [this](auto count) {
if (count) {
m_isFavourite = (*count).count > 0;
Q_EMIT isFavouriteChanged();
}
});
@@ -263,7 +226,7 @@ bool FavouriteWatcher::isFavourite() const {
return m_isFavourite;
}
SearchHistoryModel::SearchHistoryModel(QFuture<std::vector<QString>> &&historyFuture, QObject *parent)
SearchHistoryModel::SearchHistoryModel(QFuture<std::vector<Search> > &&historyFuture, QObject *parent)
: QAbstractListModel(parent)
{
connectFuture(historyFuture, this, [this](const auto history) {
@@ -280,7 +243,7 @@ int SearchHistoryModel::rowCount(const QModelIndex &parent) const {
QVariant SearchHistoryModel::data(const QModelIndex &index, int role) const {
switch (role) {
case Qt::DisplayRole:
return m_history[index.row()];
return m_history[index.row()].query;
}
Q_UNREACHABLE();
+30 -4
View File
@@ -16,8 +16,32 @@
class FavouriteWatcher;
struct Count {
using ColumnTypes = std::tuple<int>;
static Count fromSql(ColumnTypes tuple) {
auto [count] = tuple;
return Count { count };
}
int count;
};
struct Search {
using ColumnTypes = std::tuple<QString>;
static Search fromSql(ColumnTypes tuple) {
auto [query] = tuple;
return Search { query };
}
QString query;
};
struct Song {
static Song fromSql(std::tuple<QString, QString, QString, QString> tuple) {
using ColumnTypes = std::tuple<QString, QString, QString, QString>;
static Song fromSql(ColumnTypes tuple) {
auto [videoId, title, artist, album] = tuple;
return Song {videoId, title, artist, album};
}
@@ -49,7 +73,9 @@ private:
};
struct PlayedSong {
static PlayedSong fromSql(std::tuple<QString, int, QString, QString, QString> tuple) {
using ColumnTypes = std::tuple<QString, int, QString, QString, QString>;
static PlayedSong fromSql(ColumnTypes tuple) {
auto [videoId, plays, title, artist, album] = tuple;
return PlayedSong {videoId, title, artist, album, plays};
}
@@ -86,12 +112,12 @@ class SearchHistoryModel : public QAbstractListModel {
Q_OBJECT
public:
SearchHistoryModel(QFuture<std::vector<QString> > &&historyFuture, QObject *parent = nullptr);
SearchHistoryModel(QFuture<std::vector<Search> > &&historyFuture, QObject *parent = nullptr);
int rowCount(const QModelIndex &parent) const override;
QVariant data(const QModelIndex &index, int role) const override;
private:
std::vector<QString> m_history;
std::vector<Search> m_history;
};
class Library : public QObject
+2
View File
@@ -14,6 +14,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<file alias="PlaylistPage.qml">contents/ui/PlaylistPage.qml</file>
<file alias="ConfirmationMessage.qml">contents/ui/ConfirmationMessage.qml</file>
<file alias="LibraryPage.qml">contents/ui/LibraryPage.qml</file>
<file alias="MinimizedPlayerControls.qml">contents/ui/MinimizedPlayerControls.qml</file>
<file alias="MaximizedPlayerPage.qml">contents/ui/MaximizedPlayerPage.qml</file>
<file>migrations/2022-05-20-194850_init/down.sql</file>
<file>migrations/2022-05-20-194850_init/up.sql</file>
</qresource>
+14
View File
@@ -17,6 +17,8 @@ VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
m_videoInfo = {};
Q_EMIT audioUrlChanged();
Q_EMIT titleChanged();
Q_EMIT artistChanged();
Q_EMIT channelChanged();
Q_EMIT songChanged();
Q_EMIT thumbnailChanged();
return;
@@ -30,6 +32,8 @@ VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
setLoading(false);
Q_EMIT audioUrlChanged();
Q_EMIT titleChanged();
Q_EMIT artistChanged();
Q_EMIT channelChanged();
Q_EMIT songChanged();
Q_EMIT thumbnailChanged();
});
@@ -81,6 +85,16 @@ QString VideoInfoExtractor::title() const
return QString::fromStdString(m_videoInfo.title);
}
QString VideoInfoExtractor::artist() const
{
return QString::fromStdString(m_videoInfo.artist);
}
QString VideoInfoExtractor::channel() const
{
return QString::fromStdString(m_videoInfo.channel);
}
QString VideoInfoExtractor::thumbnail() const
{
return QString::fromStdString(m_videoInfo.thumbnail);
+8
View File
@@ -17,6 +17,8 @@ class VideoInfoExtractor : public QObject
Q_PROPERTY(QUrl audioUrl READ audioUrl NOTIFY audioUrlChanged)
Q_PROPERTY(QString title READ title NOTIFY titleChanged)
Q_PROPERTY(QString artist READ artist NOTIFY artistChanged) // may be empty if it is a video
Q_PROPERTY(QString channel READ channel NOTIFY channelChanged)
Q_PROPERTY(QString thumbnail READ thumbnail NOTIFY thumbnailChanged)
Q_PROPERTY(bool loading READ loading WRITE setLoading NOTIFY loadingChanged)
@@ -32,6 +34,12 @@ public:
QString title() const;
Q_SIGNAL void titleChanged();
QString artist() const;
Q_SIGNAL void artistChanged();
QString channel() const;
Q_SIGNAL void channelChanged();
QString thumbnail() const;
Q_SIGNAL void thumbnailChanged();
+3 -1
View File
@@ -460,10 +460,12 @@ video_info::VideoInfo YTMusic::extract_video_info(const std::string &video_id) c
using namespace pybind11::literals;
const auto info = d->get_ytdl().attr("extract_info")(video_id, "download"_a=py::bool_(false));
return {
info["id"].cast<std::string>(),
info["title"].cast<std::string>(),
info.contains("artist") ? info["artist"].cast<std::string>() : "",
info.contains("channel") ? info["channel"].cast<std::string>() : "",
extract_py_list<video_info::Format>(info["formats"]),
info["thumbnail"].cast<std::string>()
};
+2
View File
@@ -241,6 +241,8 @@ struct Format {
struct VideoInfo {
std::string id;
std::string title;
std::string artist;
std::string channel;
std::vector<Format> formats;
std::string thumbnail;