Inertial chicken

This commit is contained in:
Daniel O'Neill 2026-01-09 20:21:55 -08:00
parent 0927bd8d6e
commit 2c9f8932c0
26 changed files with 2987 additions and 0 deletions

18
Audiobooks.desktop Normal file
View file

@ -0,0 +1,18 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=Audiobook Player
Comment=Listen to audiobooks
GenericName=Audiobook Player
Exec=Audiobooks %U
TryExec=Audiobooks
Icon=Audiobooks
Terminal=false
Categories=AudioVideo;Audio;Player;
MimeType=audio/mpeg;audio/mp4;audio/x-m4b;
StartupNotify=true

BIN
Audiobooks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

63
Audiobooks.pro Normal file
View file

@ -0,0 +1,63 @@
QT += core quick widgets dbus sql
# The following define makes your compiler emit warnings if you use
# any feature of Qt which as been marked deprecated (the exact warnings
# depend on your compiler). Please consult the documentation of the
# deprecated API in order to know how to port your code away from it.
DEFINES += QT_DEPRECATED_WARNINGS
# You can also make your code fail to compile if you use deprecated APIs.
# In order to do so, uncomment the following line.
# You can also select to disable deprecated APIs only up to a certain version of Qt.
#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0
SOURCES += \
src/file.cpp \
src/librarydb.cpp \
src/main.cpp \
src/mpris.cpp \
src/mprisadaptor.cpp \
src/mprisplayer.cpp \
src/screen.cpp \
src/thumbnailprovider.cpp \
src/mpvaudio.cpp
HEADERS += \
src/file.h \
src/librarydb.h \
src/mpris.h \
src/mprisadaptor.h \
src/mprisplayer.h \
src/screen.h \
src/thumbnailprovider.h \
src/mpvaudio.h
CONFIG += c++20 link_pkgconfig
PKGCONFIG += mpv
#PKGCONFIG += KF6KIOCore KF6KIOGui
LIBS += -lKF6KIOCore -lKF6KIOGui -lKF6CoreAddons
INCLUDEPATH += /usr/include/KF6/KIOGui /usr/include/KF6/KIO /usr/include/KF6/KIOCore /usr/include/KF6/KCoreAddons
# Additional import path used to resolve QML modules in Qt Creator's code model
QML_IMPORT_PATH =
# Additional import path used to resolve QML modules just for Qt Quick Designer
QML_DESIGNER_IMPORT_PATH =
# Default rules for deployment.
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target
DISTFILES = \
$$files(qml/*, true) \
qml/AProgressBar.qml \
qml/PageNowPlaying.qml
resources.prefix = /
resources.base = $$PWD
resources.files = \
$$files(qml/*, true)
RESOURCES += resources

23
qml/AProgressBar.qml Normal file
View file

@ -0,0 +1,23 @@
import QtQuick
import org.kde.kirigami as Kirigami
Rectangle {
id: progressBar
color: "transparent"
border.color: Kirigami.Theme.alternateBackgroundColor
border.width: 1
radius: Kirigami.Units.cornerRadius
implicitHeight: Kirigami.Units.largeSpacing
height: Kirigami.Units.largeSpacing
property real value
Rectangle {
radius: Kirigami.Units.cornerRadius
x: Kirigami.Units.smallSpacing * 0.5
y: Kirigami.Units.smallSpacing * 0.5
width: (parent.width - Kirigami.Units.smallSpacing) * parent.value
height: parent.height - Kirigami.Units.smallSpacing
color: Kirigami.Theme.focusColor
}
}

576
qml/Manager.qml Normal file
View file

@ -0,0 +1,576 @@
import QtQuick
import QtCore
Item {
id: manager
property var m_model
property var m_files
property string m_home
property string m_path
property QtObject m_db
property variant m_toParse: []
property variant m_library: []
property bool playing: false
property bool paused: false
property bool loaded: false
property real position: 0
property variant m_currentBook: false
property variant m_currentChapter: { "index":0, "chapterPosition":0, "chapterLength":0, "chapterProgress":0, "title":"Not Playing", "chapterStart":-1, "chapterEnd":-1 }
signal contentsChanged(variant contents)
signal libraryEntryUpdated(int idx, variant entry);
// Navigation:
function goHome() {
setPath( m_home );
}
function goUp() {
let parts = m_path.split(/\//g);
parts.pop();
let npath = parts.join('/');
if( "file://" === npath )
npath = "file:///";
setPath(npath);
}
function setPath(path) {
m_path = path;
m_model = File.readDir(m_path);
/** Can't use filter/map here cuz race on idx set, so... **/
let files = [];
for( let a=0, b=0; a < m_model.length; a++ )
{
let obj = m_model[a];
if( obj.type === "dir" )
continue;
obj["source"] = m_path + "/" + obj["name"];
obj["index"] = a;
obj["fileindex"] = b++;
files.push(obj);
}
m_files = files;
for( let f of files )
dbCheck(f);
dbParsePending();
}
// Utility:
function formatSeconds(secs) {
let s = 0+secs;
if(s < 0 || s === undefined)
return "Unknown";
const hours = Math.floor(s / 3600);
s -= hours * 3600;
const minutes = Math.floor(s / 60);
s -= minutes * 60;
const seconds = Math.floor(s);
if( hours < 1 )
return minutes + ":" + (seconds < 10 ? "0" + seconds : seconds);
return hours + ":" + (minutes < 10 ? "0" + minutes : minutes) + ":" + (seconds < 10 ? "0" + seconds : seconds);
}
function play() {
if( !m_currentBook ) {
console.log("Manager.play(): Cannot play. No current book.");
return false;
}
if( !MPV.loaded )
return console.log("Manager.play(): Cannot play. MPV isn't loaded/ready.");
/*
if( !MPV.loaded )
return bookPlay(m_currentBook["id"]);
*/
console.log(`play()`);
return MPV.play();
}
function pause() {
if( MPV.loaded && MPV.playing )
return MPV.pause();
return false;
}
function playPause() {
if( !MPV.loaded )
return play();
if( MPV.paused )
return play();
return pause();
}
function setPosition(pos) {
console.log(`setPosition(${pos})`);
MPV.position = pos;
}
// Library functions:
function bookPlay(bookid, position) {
console.log(`Manager.bookPlay(${bookid}, ${position})`);
if( m_currentBook && m_currentBook["id"] === bookid)
{
console.log(`On current book, taking shortcut...`);
if( position )
setPosition(position);
return play();
}
for( let book of manager.m_library )
{
if( book["id"] === bookid )
{
MPV.paused = false;
MPV.stop();
let thenPlay = function() {
if( !position )
position = book["position"];
let onLoad = function() {
if( !MPV.loaded )
return;
if( position )
MPV.position = position;
MPV.loadedChanged.disconnect(onLoad);
MPV.play();
};
MPV.loadedChanged.connect(onLoad);
MPV.load(book["path"]);
}
m_currentBook = book;
if( !m_currentBook["artUrl"] )
{
// Get album art, spit it into a cache file:
const cachePath = StandardPaths.writableLocation(StandardPaths.CacheLocation);
const newUrl = `${cachePath}/${m_currentBook["id"]}.png`;
const cachedPath = "image://thumbnails/" + encodeURIComponent(m_currentBook["path"]);
let thumber = thumbnail.createObject(manager, {"source":cachedPath, "destination":newUrl});
thumber.done.connect(function(success) {
console.log("Thumber done.");
if( success )
m_currentBook["artUrl"] = newUrl;
thenPlay();
Qt.callLater( function() {
thumber.destroy();
});
});
} else {
thenPlay();
}
return;
}
}
console.log(`Couldn't find book with ID ${bookid}!`);
}
Component {
id: thumbnail
Image {
id: image
width: 256
height: 256
fillMode: Image.PreserveAspectFit
visible: false
required property string destination
signal done(bool success)
onStatusChanged: {
if( status === Image.Ready )
{
image.grabToImage(function(result) {
result.saveToFile(destination);
console.log(`image captured to ${destination}!`);
image.done(true);
});
}
else if( status === Image.Error )
{
console.log("image.grab failed.");
image.done(false);
}
}
}
}
function playChapter(chapidx) {
if( !m_currentBook )
return;
let ch = m_currentBook.chapters[chapidx];
setPosition(ch["start"]);
play();
}
function chapterSeek(rel) {
if( !m_currentBook || !m_currentChapter )
return;
if( rel < 0 || rel > 1 )
return;
const newPos = m_currentChapter["chapterStart"] + (m_currentChapter["chapterLength"] * rel);
setPosition(newPos);
}
// Database:
Timer {
id: previewTimer
interval: 100
repeat: false
property bool doMetadata: false
property bool doChapters: false
onTriggered: handlePreviewLoaded();
function handlePreviewLoaded() {
MPVPreview.stop();
if( doMetadata ) {
const path = LibraryDb.escape("file://" + MPVPreview.source);
const title = LibraryDb.escape( MPVPreview.metadata["title"]);
const author = LibraryDb.escape(MPVPreview.metadata["artist"]);
const desc = LibraryDb.escape(MPVPreview.metadata["comment"]);
const length = LibraryDb.escape(MPVPreview.duration);
LibraryDb.exec(`UPDATE library SET parsed=1, title='${title}', author='${author}', description='${desc}', length='${length}' WHERE path='${path}'`);
}
if( doChapters ) {
const path = LibraryDb.escape("file://" + MPVPreview.source);
const ents = LibraryDb.query(`SELECT id FROM library WHERE path='${path}'`);
const lid = ents[0]["id"];
if( !lid )
{
console.log(`Couldn't find ID for ${path} in the database.`);
return;
}
LibraryDb.exec(`DELETE FROM chapters WHERE bookid=${lid}`);
for( let chap of MPVPreview.chapters )
{
const title = LibraryDb.escape(chap["title"]);
LibraryDb.exec(`INSERT INTO chapters (bookid, start, title)VALUES(${lid}, ${chap["time"]}, '${title}')`);
}
}
MPVPreview.
doMetadata = doChapters = false;
dbParsePending();
}
}
Timer {
id: positionTimer
interval: 30000
repeat: true
running: MPV.playing && !MPV.paused && MPV.loaded
triggeredOnStart: true
onTriggered: manager.savePosition();
}
function savePosition() {
if( !m_currentBook )
return;
const id = m_currentBook["id"];
const position = MPV.position;
LibraryDb.exec(`UPDATE library SET position=${position} WHERE id=${id}`)
const idx = m_currentBook["index"];
m_currentBook["position"] = position;
m_library[idx] = m_currentBook; // Update "position" record for LibraryPage.
libraryEntryUpdated(idx, m_library[idx]);
console.log("New position saved!");
}
function initMpris() {
MPRIS.play.connect( function() {
console.log("play received via MPRIS2");
MPV.play();
} );
MPRIS.pause.connect( function() {
console.log("pause received via MPRIS2");
MPV.pause();
} );
MPRIS.playPause.connect( function() {
console.log("playPause received via MPRIS2");
MPV.togglePause();
} );
MPRIS.stop.connect( function() {
console.log("stop received via MPRIS2");
MPV.stop();
} );
MPRIS.next.connect( function() {
console.log("next received via MPRIS2");
const newIdx = manager.m_currentChapter["index"] + 1;
manager.playChapter( newIdx );
} );
MPRIS.previous.connect( function() {
console.log("previous received via MPRIS2");
const newIdx = manager.m_currentChapter["index"] - 1;
manager.playChapter( newIdx );
} );
MPRIS.seek.connect( function(offset) {
console.log(`seek ${offset}s received via MPRIS2`);
if( offset > 0 ) {
if( MPV.position + offset < manager.m_currentChapter["chapterEnd"] )
MPV.position += offset;
else
MPV.position = manager.m_currentChapter["chapterEnd"];
} else {
if( MPV.position - offset > manager.m_currentChapter["chapterStart"] )
MPV.position += offset;
else
MPV.position = manager.m_currentChapter["chapterStart"];
}
} );
MPRIS.setPosition.connect( function(position) {
console.log(`setPosition ${position}s received via MPRIS2`);
} );
MPRIS.setRate.connect( function(rate) {
console.log(`setRate ${rate}s received via MPRIS2`);
} );
MPRIS.setVolume.connect( function(volume) {
console.log(`setVolume ${volume}s received via MPRIS2`);
} );
}
function mprisUpdateMetadata()
{
let obj = { "id":m_currentBook["id"],
"chapter":m_currentChapter["index"],
"chapterCount":m_currentBook["chapters"].length,
"chapterTitle":m_currentChapter["title"],
"artist":m_currentBook["author"],
"title":m_currentBook["title"],
"duration":m_currentChapter["chapterLength"] };
if( m_currentBook["artUrl"] )
obj["artUrl"] = m_currentBook["artUrl"];
MPRIS.updateMetadata(obj);
}
Timer {
interval: 1000
repeat: true
onTriggered: mprisUpdatePosition();
running: MPV.playing && !MPV.paused
}
function mprisUpdatePosition()
{
MPRIS.updatePosition(m_currentChapter["chapterPosition"]);
}
function initMpv() {
MPV.loadedChanged.connect( function() {
manager.loaded = MPV.loaded;
});
MPV.playingChanged.connect( function() {
manager.playing = MPV.playing;
Qt.callLater( function() {
ScreenOps.setInhibited(MPV.playing && !MPV.paused, MPV.mediaTitle);
} );
if( MPV.paused )
MPRIS.updateStatus("Paused");
else if( MPV.playing )
MPRIS.updateStatus("Playing");
else
MPRIS.updateStatus("Stopped");
});
MPV.pausedChanged.connect( function() {
manager.paused = MPV.paused;
if( MPV.paused )
MPRIS.updateStatus("Paused");
else if( MPV.playing )
MPRIS.updateStatus("Playing");
else
MPRIS.updateStatus("Stopped");
});
MPV.positionChanged.connect( function() {
if( !manager.m_currentBook )
return;
const realPos = MPV.position;
manager.position = realPos;
if( !manager.m_currentChapter
|| realPos <= manager.m_currentChapter["chapterStart"]
|| realPos > manager.m_currentChapter["chapterEnd"] )
{
// Find which chapter we're in:
let chapIndex = 0;
let start = 0;
let chapterLength = 0;
let title = "";
for( let a=0; a < manager.m_currentBook.chapters.length; a++ )
{
let ch = manager.m_currentBook.chapters[a];
if( ch["start"] > realPos ) {
if( a === manager.m_currentBook["chapters"].length - 1 ) {
chapIndex = a;
start = ch["start"];
chapterLength = manager.m_currentBook["length"] - start;
title = ch["title"];
} else if( a > 0 ) {
chapIndex = a-1;
let prev = manager.m_currentBook.chapters[chapIndex];
start = prev["start"];
chapterLength = ch["start"] - prev["start"];
title = prev["title"];
} else
chapterLength = ch["start"];
break;
}
}
let chapterPosition = realPos - start;
let chapterProgress = chapterPosition / chapterLength;
console.log(`Chapter changed, new progress: ${chapterProgress}`);
manager.m_currentChapter = {
"index":chapIndex,
"chapterStart":start,
"chapterEnd":start+chapterLength,
"chapterPosition":chapterPosition,
"chapterLength":chapterLength,
"chapterProgress":chapterProgress,
"title":title
};
mprisUpdateMetadata();
mprisUpdatePosition();
savePosition();
} else {
let chapterPosition = realPos - manager.m_currentChapter["chapterStart"];
let chapterProgress = chapterPosition / manager.m_currentChapter["chapterLength"];
//console.log(`Chapter progress: ${chapterProgress}`);
manager.m_currentChapter = {
"index":manager.m_currentChapter["index"],
"chapterStart":manager.m_currentChapter["chapterStart"],
"chapterEnd":manager.m_currentChapter["chapterEnd"],
"chapterPosition":chapterPosition,
"chapterLength":manager.m_currentChapter["chapterLength"],
"chapterProgress":chapterProgress,
"title":manager.m_currentChapter["title"]
};
}
} );
MPVPreview.metadataChanged.connect( function() {
if( Object.keys(MPVPreview.metadata).length === 0 )
return;
previewTimer.doMetadata = true;
} );
MPVPreview.chaptersChanged.connect( function() {
if( MPVPreview.chapters.length < 1 )
return;
previewTimer.doChapters = true;
});
MPVPreview.loadedChanged.connect( function() {
if( !MPVPreview.loaded )
return;
Qt.callLater( function() {
previewTimer.handlePreviewLoaded();
} );
});
}
function dbInit() {
if( !LibraryDb.open() )
return console.error("Failed to open database!");
dbUpdate();
}
function dbParsePending() {
if( m_toParse.length < 1 ) {
dbLoadLibrary();
console.log(`All details read!`);
return;
}
let ent = m_toParse.pop();
const plainpath = ent["fullpath"].substring(7);
console.log(`Reading details for ${ent["fullpath"]}...`);
//previewTimer.start();
MPVPreview.volume = 0;
MPVPreview.load( plainpath );
}
function dbUpdate() {
LibraryDb.exec("CREATE TABLE IF NOT EXISTS library (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, modified DATETIME, size BIGINT, parsed TINYINT(1), title TEXT, author TEXT, description TEXT, length DECIMAL(8,2), position DECIMAL(8,2))");
LibraryDb.exec("CREATE TABLE IF NOT EXISTS chapters (id INTEGER PRIMARY KEY AUTOINCREMENT, bookid INT NOT NULL, start DECIMAL(8,2), title TEXT)");
}
function dbNewEntry(ent) {
const path = LibraryDb.escape(ent["fullpath"]);
const modified = LibraryDb.escape(ent["modified"]);
const size = LibraryDb.escape(ent["size"]);
LibraryDb.exec(`INSERT INTO library (path, modified, size, parsed)VALUES('${path}', '${modified}', '${size}', 0)`);
dbQueueParse(ent);
}
function dbQueueParse(ent)
{
m_toParse.push( ent );
}
function dbCheck(ent) {
const qstr = `SELECT id, path, modified, size, parsed, title, author, description, length, position FROM library WHERE path='${LibraryDb.escape(ent["fullpath"])}'`;
let rows = LibraryDb.query(qstr);
if( 0 === rows.length )
return dbNewEntry(ent);
if( 1 !== rows[0]["parsed"] )
return dbQueueParse(ent);
}
function dbLoadLibrary() {
let books = LibraryDb.query("SELECT id, path, modified, size, title, author, description, length, position FROM library");
for( let a=0; a < books.length; a++ ) {
let b = books[a];
// A little cleanup:
b["index"] = a;
b["description"] = b["description"].replace(/\\"/g, '"').replace(/\\n/g, '');
b["chapters"] = LibraryDb.query(`SELECT id, start, title FROM chapters WHERE bookid=${b['id']}`);
}
manager.m_library = books;
//console.log(JSON.stringify(books,null,4));
}
}

130
qml/MiniPlayerOverlay.qml Normal file
View file

@ -0,0 +1,130 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
Item {
id: playerBar
readonly property real miniPlayerHeight: playerLayout.implicitHeight + (Kirigami.Units.largeSpacing * 2)
height: visible ? playerBar.opacity * miniPlayerHeight : 0
visible: opacity > 0
opacity: playerBar.wantShown ? 1 : 0
//Behavior on opacity { PropertyAnimation { duration: 200 } }
property bool wantShown: false
signal cardClicked()
Kirigami.AbstractCard {
id: playerCard
anchors {
top: parent.top
horizontalCenter: parent.horizontalCenter
}
width: playerBar.width - (Kirigami.Units.largeSpacing * 2)
height: playerLayout.implicitHeight + Kirigami.Units.largeSpacing // FIXME: This is needed for ProgressBar to show... broken stinky logic here.
showClickFeedback: true
TapHandler {
onSingleTapped: {
playerBar.cardClicked();
}
}
GridLayout {
id: playerLayout
anchors.fill: parent
anchors.margins: Kirigami.Units.smallSpacing
columns: 5
columnSpacing: Kirigami.Units.mediumSpacing
rowSpacing: Kirigami.Units.smallSpacing
clip: true
Image {
Layout.rowSpan: 3
Layout.fillHeight: true
Layout.preferredWidth: height
fillMode: Image.PreserveAspectCrop
source: manager.m_currentBook ? "image://thumbnails/" + encodeURIComponent(manager.m_currentBook["path"]) : ""
smooth: true
}
Kirigami.Heading {
Layout.fillWidth: true
Layout.columnSpan: 4
elide: Text.ElideRight
level: 2
text: manager.m_currentBook ? manager.m_currentBook["title"] : ""
}
QQC2.Label {
Layout.fillWidth: true
elide: Text.ElideRight
color: Kirigami.Theme.textColor
text: manager.m_currentBook ? manager.m_currentBook["author"] : ""
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
}
QQC2.ToolButton {
icon.name: "media-seek-backward"
Layout.rowSpan: 2
enabled: manager.m_currentChapter["index"] > 0
onClicked: {
const newIdx = manager.m_currentChapter["index"] - 1;
manager.playChapter( newIdx );
}
}
QQC2.ToolButton {
icon.name: manager.playing ? "media-playback-pause" : "media-playback-start"
Layout.rowSpan: 2
onClicked: manager.playPause();
}
QQC2.ToolButton {
icon.name: "media-seek-forward"
Layout.rowSpan: 2
enabled: manager.m_currentBook ? manager.m_currentChapter["index"] < (manager.m_currentBook["chapters"].length - 1) : false
onClicked: {
const newIdx = manager.m_currentChapter["index"] + 1;
manager.playChapter( newIdx );
}
}
QQC2.Label {
Layout.fillWidth: true
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
color: Kirigami.Theme.textColor
text: manager.m_currentChapter["title"]
}
/*
// --- NOTE ---
// DO NOT USE ProgressBar!
// It hammers the CPU at IDLE as of Qt 6.10.1 which pins a mobile CPU core to 100%!
// ------------
QQC2.ProgressBar {
id: progressBar
Layout.fillWidth: true
Layout.columnSpan: 5
from: 0
to: 1
value: manager.m_currentChapter["chapterProgress"]
//visible: manager.m_currentChapter["chapterProgress"] > 0 && manager.m_currentChapter["chapterProgress"] < 1
onValueChanged: console.log(`Value changed: ${value}`);
}
*/
AProgressBar {
id: progressBar
Layout.fillWidth: true
Layout.columnSpan: 5
value: manager.m_currentChapter["chapterProgress"]
}
}
}
}

214
qml/PageLibrary.qml Normal file
View file

@ -0,0 +1,214 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
Kirigami.Page {
id: libraryPageItem
title: qsTr("Library")
focus: false
property bool searchMode: false
property alias bookListModel: bookListView.model
//property var bookListModel
signal cardClicked(variant cardInfo)
header: Kirigami.SearchField {
visible: opacity > 0
height: libraryPageItem.searchMode ? implicitHeight : 0
opacity: height < implicitHeight ? (height / implicitHeight) : 1
Behavior on height { PropertyAnimation { duration: 150 } }
onAccepted: libraryPageItem.searchTextChanged(text)
focus: true
Keys.onEscapePressed: libraryPageItem.searchMode = false
}
actions: [
Kirigami.Action {
icon.name: "search"
onTriggered: {
libraryPageItem.searchMode = !libraryPageItem.searchMode;
}
}
]
ColumnLayout {
anchors.fill: parent
spacing: 0
//Kirigami.CardsListView
ListView {
id: bookListView
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
delegate: Kirigami.AbstractCard { // QQC2.Pane {
id: card
required property variant modelData
required property int index
readonly property bool isPlaying: (manager.m_currentBook && modelData["path"] === manager.m_currentBook["path"]) ? true : false
height: cardInner.implicitHeight + Kirigami.Units.smallSpacing*2
clip: true
showClickFeedback: true
TapHandler {
onSingleTapped: {
libraryPageItem.cardClicked(modelData);
}
}
ColumnLayout {
id: cardInner
width: parent.width - Kirigami.Units.largeSpacing
anchors.centerIn: parent
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
Image {
Layout.preferredHeight: Kirigami.Units.gridUnit * 4
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
source: "image://thumbnails/" + encodeURIComponent(modelData.path)
fillMode: Image.PreserveAspectCrop
smooth: true
}
ColumnLayout {
Layout.fillWidth: true
clip: true
QQC2.Label {
Layout.fillWidth: true
text: modelData.title
elide: Text.ElideRight
font.bold: true
}
QQC2.Label {
Layout.fillWidth: true
elide: Text.ElideRight
text: modelData.author
}
QQC2.Label {
id: textPosition
Layout.fillWidth: true
elide: Text.ElideRight
text: card.isPlaying && manager.position >= 0 ?
qsTr("%1 / %2").arg(manager.formatSeconds(manager.position)).arg(manager.formatSeconds(modelData.length))
: progressBar.timeposition > 0 ?
qsTr("%1 / %2").arg(manager.formatSeconds(progressBar.timeposition)).arg(manager.formatSeconds(modelData.length)) : manager.formatSeconds(modelData.length)
}
}
ColumnLayout {
QQC2.ToolButton {
icon.name: card.isPlaying && manager.playing ? "media-playback-pause" : "media-playback-start"
onClicked: {
if( !card.isPlaying )
return manager.bookPlay(modelData.id);
manager.playPause();
}
}
/*
QQC2.ToolButton {
icon.name: "overflow-menu"
onClicked: {
cardSheet.open();
}
Kirigami.OverlayDrawer {
id: cardSheet
edge: Qt.BottomEdge
modal: true
handleVisible: true
contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
//padding: Kirigami.Units.largeSpacing
Repeater {
model: cardSheet.actions
delegate: Kirigami.NavigationTabButton {
display: Kirigami.NavigationTabButton.TextBesideIcon
text: modelData.text
icon.name: modelData.icon.name
Layout.fillWidth: true
onClicked: {
cardSheet.close();
modelData.trigger();
}
}
}
}
property list<Kirigami.Action> actions: [
Kirigami.Action { text: "Play"; icon.name: "media-playback-start"; onTriggered: {
manager.bookPlay(modelData.id);
} },
Kirigami.Action { text: "Share"; icon.name: "document-share"; onTriggered: console.log("share") },
Kirigami.Action { text: "Delete"; icon.name: "edit-delete"; onTriggered: console.log("delete") }
]
}
}
*/
}
}
/*
// --- NOTE ---
// DO NOT USE ProgressBar!
// It hammers the CPU at IDLE as of Qt 6.10.1 which pins a mobile CPU core to 100%!
// ------------
QQC2.ProgressBar {
id: progressBar
Layout.fillWidth: true
property real timeposition: modelData.position
readonly property real progress: timeposition ? (timeposition / modelData.length) : 0
from: 0
to: 1
value: progress
visible: progress > 0 && progress < 1
}
Connections {
target: manager
function onLibraryEntryUpdated(idx, entry) {
console.log("HUP.");
if( index !== idx )
return;
progressBar.timeposition = entry.position;
}
}
*/
AProgressBar {
id: progressBar
Layout.fillWidth: true
Layout.columnSpan: 5
property real timeposition: modelData.position
readonly property real progress: timeposition ? (timeposition / modelData.length) : 0
value: progress
visible: progress > 0 && progress < 1
Connections {
target: manager
function onLibraryEntryUpdated(idx, entry) {
if( index !== idx )
return;
progressBar.timeposition = entry.position;
}
}
}
}
}
} // ListView
} // ColumnLayout
} // Page

299
qml/PageNowPlaying.qml Normal file
View file

@ -0,0 +1,299 @@
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
Kirigami.Page {
id: nowPlayingPageItem
title: isPlaying ? qsTr("Now Playing") : qsTr("Book Details")
property variant m_book
signal closePage()
readonly property bool isPlaying: (m_book && manager.m_currentBook && m_book["path"] === manager.m_currentBook["path"]) ? true : false
actions: [
Kirigami.Action {
icon.name: "go-previous"
onTriggered: {
closePage();
}
}
]
Flickable {
id: scrollView
anchors.fill: parent
/*
QQC2.ScrollBar.vertical.policy: QQC2.ScrollBar.AlwaysOff
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
contentWidth: -1 // disable horizontal scroll
*/
contentWidth: parent.width
contentHeight: column.height
ColumnLayout {
id: column
width: scrollView.width
spacing: Kirigami.Units.largeSpacing
Image {
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: column.width * 0.75
//Layout.preferredWidth: height
fillMode: Image.PreserveAspectFit
source: nowPlayingPageItem.m_book ? "image://thumbnails/" + encodeURIComponent(nowPlayingPageItem.m_book["path"]) : ""
}
Kirigami.Heading {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
text: nowPlayingPageItem.m_book ? nowPlayingPageItem.m_book["title"] : ""
wrapMode: Text.Wrap
}
Kirigami.Heading {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
level: 3
text: nowPlayingPageItem.m_book ? qsTr("By <b>%1</b>").arg(nowPlayingPageItem.m_book["author"]) : ""
wrapMode: Text.Wrap
}
QQC2.Label {
id: labelBookTime
font: Kirigami.Theme.smallFont
color: Kirigami.Theme.textColor
text: nowPlayingPageItem.m_book ?
nowPlayingPageItem.m_book["position"] > 0 ? qsTr("%1 / %2").arg(manager.formatSeconds(nowPlayingPageItem.m_book["position"])).arg(manager.formatSeconds(nowPlayingPageItem.m_book["length"]))
: qsTr("%1").arg(manager.formatSeconds(nowPlayingPageItem.m_book["length"]))
: ""
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
Connections {
target: MPV
function onPositionChanged() {
labelBookTime.text = qsTr("%1 / %2").arg(manager.formatSeconds(manager.position)).arg(manager.formatSeconds(nowPlayingPageItem.m_book["length"]));
}
}
}
Kirigami.Separator {
weight: Kirigami.Separator.Weight.Light
Layout.fillWidth: true
}
QQC2.Label {
font: Kirigami.Theme.defaultFont
color: Kirigami.Theme.textColor
text: nowPlayingPageItem.m_book ? nowPlayingPageItem.m_book["description"] : ""
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
Kirigami.Separator {
weight: Kirigami.Separator.Weight.Light
Layout.fillWidth: true
}
ColumnLayout {
visible: nowPlayingPageItem.isPlaying
Layout.preferredWidth: parent.width * 0.75
Layout.alignment: Qt.AlignHCenter
RowLayout {
Layout.alignment: Qt.AlignHCenter
QQC2.ToolButton {
icon.name: "media-skip-backward"
enabled: manager.m_currentChapter["index"] > 0
onClicked: {
const newIdx = manager.m_currentChapter["index"] - 1;
manager.playChapter( newIdx );
}
display: QQC2.AbstractButton.TextUnderIcon
text: qsTr("Prev")
}
QQC2.ToolButton {
icon.name: "go-previous-skip"
onClicked: {
if( manager.position + 30 > manager.m_currentChapter["chapterStart"] )
manager.setPosition(manager.position - 30);
else
manager.setPosition(manager.m_currentChapter["chapterStart"]);
}
display: QQC2.AbstractButton.TextUnderIcon
text: qsTr("30s")
}
QQC2.ToolButton {
icon.name: "go-previous"
onClicked: {
if( manager.position + 5 > manager.m_currentChapter["chapterStart"] )
manager.setPosition(manager.position - 5);
else
manager.setPosition(manager.m_currentChapter["chapterStart"]);
}
display: QQC2.AbstractButton.TextUnderIcon
text: qsTr("5s")
}
QQC2.ToolButton {
Layout.fillWidth: true
icon.name: manager.playing ? "media-playback-pause" : "media-playback-start"
onClicked: manager.playPause();
display: QQC2.AbstractButton.TextUnderIcon
text: manager.playing ? qsTr("Pause") : qsTr("Play")
}
QQC2.ToolButton {
icon.name: "go-next"
onClicked: {
if( manager.position + 5 < manager.m_currentChapter["chapterEnd"] )
manager.setPosition(manager.position + 5);
else
manager.setPosition(manager.m_currentChapter["chapterEnd"]);
}
display: QQC2.AbstractButton.TextUnderIcon
text: qsTr("5s")
}
QQC2.ToolButton {
icon.name: "go-next-skip"
onClicked: {
if( manager.position + 30 < manager.m_currentChapter["chapterEnd"] )
manager.setPosition(manager.position + 30);
else
manager.setPosition(manager.m_currentChapter["chapterEnd"]);
}
display: QQC2.AbstractButton.TextUnderIcon
text: qsTr("30s")
}
QQC2.ToolButton {
icon.name: "media-skip-forward"
enabled: manager.m_currentBook ? manager.m_currentChapter["index"] < (manager.m_currentBook["chapters"].length - 1) : false
onClicked: {
const newIdx = manager.m_currentChapter["index"] + 1;
manager.playChapter( newIdx );
}
display: QQC2.AbstractButton.TextUnderIcon
text: qsTr("Next")
}
}
QQC2.Slider {
id: chapterScrubber
Layout.fillWidth: true
from: 0
to: 1
value: manager.m_currentChapter["chapterProgress"]
onMoved: {
manager.chapterSeek(value);
}
}
QQC2.Label {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Kirigami.Theme.smallFont.pixelSize
color: Kirigami.Theme.textColor
text: qsTr("%1 - %2 / %3").arg(manager.m_currentChapter["title"]).arg(manager.formatSeconds(manager.m_currentChapter["chapterPosition"])).arg(manager.formatSeconds(manager.m_currentChapter["chapterLength"]))
}
} // ColumnLayout (player controls)
ColumnLayout {
Layout.preferredWidth: parent.width * 0.75
Layout.alignment: Qt.AlignHCenter
spacing: Kirigami.Units.largeSpacing
QQC2.Button {
visible: !nowPlayingPageItem.isPlaying
display: QQC2.AbstractButton.TextUnderIcon
Layout.fillWidth: true
text: atStart ? qsTr("Start Book") : qsTr("Resume")
icon.name: "media-playback-start"
onClicked: manager.bookPlay(nowPlayingPageItem.m_book["id"]);
readonly property bool atStart: nowPlayingPageItem.m_book["position"] < 1
}
QQC2.Button {
display: QQC2.AbstractButton.TextUnderIcon
visible: nowPlayingPageItem.isPlaying ?
manager.m_currentBook["position"] > (manager.m_currentBook["length"] - 10)
: nowPlayingPageItem.m_book["position"] > (nowPlayingPageItem.m_book["length"] - 10)
Layout.fillWidth: true
text: qsTr("Start Over")
icon.name: "edit-reset"
onClicked: manager.bookPlay(nowPlayingPageItem.m_book["id"], 0.0001);
}
}
Kirigami.Separator {
//visible: nowPlayingPageItem.isPlaying
weight: Kirigami.Separator.Weight.Light
Layout.fillWidth: true
}
/*
Kirigami.Heading {
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
level: 3
text: qsTr("Chapters")
wrapMode: Text.Wrap
}
*/
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Repeater {
id: listviewChapters
//Layout.fillWidth: true
//Layout.preferredHeight: contentHeight > scrollView.height ? scrollView.height : contentHeight
model: nowPlayingPageItem.m_book ? nowPlayingPageItem.m_book["chapters"] : []
Kirigami.AbstractCard {
//height: innerLayout.height
//width: listviewChapters.width
Layout.fillWidth: true
Layout.preferredHeight: implicitHeight + innerLayout.implicitHeight
required property variant modelData
required property int index
clip: true
Rectangle {
anchors.fill: parent
visible: nowPlayingPageItem.isPlaying && index === manager.m_currentChapter["index"]
color: Kirigami.Theme.activeBackgroundColor
radius: Kirigami.Units.cornerRadius
anchors.margins: Kirigami.Units.smallSpacing
}
RowLayout {
id: innerLayout
anchors.fill: parent
anchors.margins: Kirigami.Units.mediumSpacing
QQC2.Label {
text: modelData["title"]
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
leftPadding: Kirigami.Units.largeSpacing
elide: Text.ElideRight
}
QQC2.ToolButton {
icon.name: "media-playback-start"
onClicked: {
if( nowPlayingPageItem.isPlaying )
return manager.playChapter( index );
manager.bookPlay(nowPlayingPageItem.m_book["id"], modelData["start"]+0.0001);
}
}
}
}
} // Repeater (chapters)
} // ColumnLayout (chapters)
} // ColumnLayout (biggun)
} // Flickable
}

181
qml/main.qml Normal file
View file

@ -0,0 +1,181 @@
import QtCore
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
Kirigami.ApplicationWindow {
id: root
width: 400
height: 720
visible: true
minimumWidth: Kirigami.Units.gridUnit * 20
minimumHeight: Kirigami.Units.gridUnit * 30
title: "Audiobook Player"
property bool showingNowPlaying: true
Manager {
id: manager
Component.onCompleted: {
manager.initMpris();
manager.initMpv();
manager.dbInit();
let locs = StandardPaths.standardLocations(StandardPaths.DocumentsLocation);
manager.m_home = locs[0] + "/Audiobooks";
manager.goHome();
// TODO: This is a dumb hack. Our "initialPage" is Library.
root.showingNowPlaying = false;
}
onContentsChanged: function(newlist) {
console.log(`New contents: ${JSON.stringify(newlist,null,4)}`);
}
}
property variant pagesModel: [
{"code":"nowplaying", "name":"Now Playing", "icon":"contact-new", "page":nowPlayingPage, "hideMiniPlayer":true },
{"code":"library", "name":"Library", "icon":"user-home", "page":libraryPage }
/*
{"name":"Recently Played", "icon":"document-open-recent"},
{"name":"Authors", "icon":"contact-new"},
{"name":"Favorites", "icon":"media-tape"},
{"name":"Series", "icon":"multimedia-player"}
*/
]
property string currentPageCode
function showPageByCode(code, properties) {
for( let page of pagesModel )
{
if( page["code"] !== code )
continue;
currentPageCode = code;
pageStack.clear(QQC2.StackView.ReplaceTransition);
pageStack.push(page.page, properties);
menuDrawer.close();
root.showingNowPlaying = page.hideMiniPlayer ? true : false;
return;
}
console.error(`showPageByCode couldn't find a page with code "${code}"`);
}
globalDrawer: Kirigami.GlobalDrawer {
id: menuDrawer
title: "Menu"
modal: true
width: Kirigami.Units.gridUnit * 14
edge: Qt.application.layoutDirection === Qt.RightToLeft ? Qt.RightEdge : Qt.LeftEdge
contentItem: ColumnLayout {
anchors.fill: parent
spacing: Kirigami.Units.smallSpacing
//padding: Kirigami.Units.largeSpacing
Kirigami.AbstractApplicationHeader {
Layout.fillWidth: true
contentItem: Kirigami.Heading {
anchors.fill: parent
anchors.margins: Kirigami.Units.largeSpacing
verticalAlignment: Text.AlignVCenter
text: "Audiobooks"
}
}
QQC2.ScrollView {
id: scrollView
Layout.fillWidth: true
Layout.fillHeight: true
QQC2.ScrollBar.vertical.policy: QQC2.ScrollBar.AlwaysOff
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
contentWidth: -1 // disable horizontal scroll
ColumnLayout {
id: column
width: scrollView.width
spacing: 0
Repeater {
model: root.pagesModel
delegate: QQC2.Button { //Kirigami.NavigationTabButton {
display: Kirigami.NavigationTabButton.TextBesideIcon
text: modelData["name"]
icon.name: modelData["icon"]
flat: true
Layout.fillWidth: true
onClicked: {
if( modelData["code"] === "nowplaying" )
root.showPageByCode(modelData["code"], {"m_book":manager.m_currentBook});
else
root.showPageByCode(modelData["code"]);
}
enabled: modelData["code"] !== "nowplaying" || (manager.playing || manager.paused)
}
}
}
}
Kirigami.Separator { Layout.fillWidth: true }
Kirigami.NavigationTabButton {
display: Kirigami.NavigationTabButton.TextBesideIcon
text: "Settings"
icon.name: "preferences-other"
Layout.fillWidth: true
onClicked: {
// stub
}
}
}
}
pageStack.initialPage: libraryPage
pageStack.spacing: 0
Component {
id: nowPlayingPage
PageNowPlaying {
visible: isCurrentPage
onClosePage: {
root.showPageByCode("library");
}
}
}
Component {
id: libraryPage
PageLibrary {
visible: isCurrentPage
bookListModel: manager.m_library
onCardClicked: function(bookInfo) {
root.showPageByCode("nowplaying", {"m_book":bookInfo});
}
}
}
/*
contextDrawer: Kirigami.ContextDrawer {
id: ctxDrawer
title: "Page Actions"
}
*/
footer: MiniPlayerOverlay {
id: playerBar
wantShown: (manager.playing || manager.paused) && !root.showingNowPlaying
onCardClicked: {
root.showPageByCode("nowplaying", {"m_book":manager.m_currentBook});
}
}
}

80
src/file.cpp Normal file
View file

@ -0,0 +1,80 @@
#include <QQmlEngine>
#include "file.h"
File::File(QObject *parent)
: QObject{parent}
{
}
QVariantList File::readDir(const QString &path)
{
QString filepath = QUrl(path).toLocalFile();
QDir d(filepath);
QStringList nameFilters;
nameFilters << "*.mp3" << "*.opus" << "*.flac" << "*.m4a" << "*.m4b" << "*.aac";
QFileInfoList ents = d.entryInfoList(nameFilters, QDir::Drives | QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot, QDir::DirsFirst | QDir::Name);
QVariantList results;
for( const QFileInfo &ent : std::as_const(ents) ) {
QString filename = ent.fileName();
QVariantMap vent;
vent["type"] = ent.isDir() ? "dir" : "file";
vent["name"] = filename;
vent["fullpath"] = "file://" + ent.absoluteFilePath();
vent["dirpath"] = "file://" + ent.absolutePath();
vent["size"] = ent.size();
vent["modified"] = ent.lastModified();
vent["mime"] = ent.isDir() ? "dir" : "file";
results.append(vent);
}
return results;
}
QByteArray File::readFile(const QString &path)
{
QString filepath = QUrl(path).toLocalFile();
QFile f(filepath);
if( !f.open(QIODevice::ReadOnly) )
{
qDebug() << "Failed to read file: " << filepath;
//qmlEngine(this)->throwError(tr("Failed to read file!"));
return QByteArray();
}
QByteArray r = f.readAll();
f.close();
return r;
}
bool File::writeFile(const QString &path, const QByteArray &data)
{
QString filepath = QUrl(path).toLocalFile();
QFile f(filepath);
if( !f.open(QIODevice::WriteOnly) )
{
qDebug() << "Failed to open file: " << filepath;
//qmlEngine(this)->throwError(tr("Failed to read file!"));
return false;
}
qint64 ops = f.write(data);
if( data.length() != ops )
return false;
f.close();
return true;
}
QString File::toBase64(const QByteArray &data)
{
return QString::fromLatin1( data.toBase64() );
}
QByteArray File::fromBase64(const QString &data)
{
return QByteArray::fromBase64(data.toLatin1());
}

28
src/file.h Normal file
View file

@ -0,0 +1,28 @@
#ifndef FILE_H
#define FILE_H
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QObject>
#include <QString>
class File : public QObject
{
Q_OBJECT
public:
explicit File(QObject *parent = nullptr);
Q_INVOKABLE QVariantList readDir(const QString &path);
Q_INVOKABLE QByteArray readFile(const QString &path);
Q_INVOKABLE bool writeFile(const QString &path, const QByteArray &data);
Q_INVOKABLE QString toBase64(const QByteArray &data);
Q_INVOKABLE QByteArray fromBase64(const QString &data);
signals:
};
#endif // FILE_H

103
src/librarydb.cpp Normal file
View file

@ -0,0 +1,103 @@
#include "librarydb.h"
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QSqlDriver>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QStandardPaths>
#include <QThread>
LibraryDb::LibraryDb(QObject *parent) : QObject(parent) {}
LibraryDb::~LibraryDb() {
close();
}
QString LibraryDb::makeDbPath() {
QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
if( !base.isEmpty() )
base = QDir(base).filePath("library.sqlite3");
return base;
}
void LibraryDb::ensureDirExists(const QString &dirPath) {
if (!dirPath.isEmpty() && !QDir(dirPath).exists())
QDir().mkpath(dirPath);
}
bool LibraryDb::open() {
if (m_db.isOpen())
return true;
m_dbPath = makeDbPath();
if (m_dbPath.isEmpty())
return false;
ensureDirExists(QFileInfo(m_dbPath).absolutePath());
m_connName = QStringLiteral("librarydb_%1_%2")
.arg(quintptr(QThread::currentThreadId()))
.arg(QDateTime::currentMSecsSinceEpoch());
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connName);
m_db.setDatabaseName(m_dbPath);
return m_db.open();
}
void LibraryDb::close() {
if (m_connName.isEmpty())
return;
if (m_db.isOpen())
m_db.close();
m_db = QSqlDatabase();
QSqlDatabase::removeDatabase(m_connName);
m_connName.clear();
}
int LibraryDb::exec(const QString &sql) {
if (!m_db.isOpen() && !open())
return -1;
QSqlQuery q(m_db);
if (!q.exec(sql))
return -1;
return q.numRowsAffected();
}
QVariantList LibraryDb::query(const QString &sql) {
QVariantList rows;
if (!m_db.isOpen() && !open())
return rows;
QSqlQuery q(m_db);
if (!q.exec(sql))
return rows;
const QSqlRecord rec = q.record();
const int cols = rec.count();
while (q.next()) {
QVariantMap row;
for (int i = 0; i < cols; ++i)
row.insert(rec.fieldName(i), q.value(i));
rows.push_back(row);
}
return rows;
}
QString LibraryDb::escape(const QString &s) {
if( !m_db.isOpen() )
return QString();
QString out = s;
out.replace('\'', "''");
return out;
}

33
src/librarydb.h Normal file
View file

@ -0,0 +1,33 @@
#pragma once
#include <QObject>
#include <QSqlDatabase>
#include <QVariantList>
#include <QVariantMap>
class LibraryDb : public QObject {
Q_OBJECT
public:
explicit LibraryDb(QObject *parent = nullptr);
~LibraryDb() override;
Q_INVOKABLE bool open();
Q_INVOKABLE void close();
Q_INVOKABLE bool isOpen() const { return m_db.isOpen(); }
Q_INVOKABLE QString dbPath() const { return m_dbPath; }
Q_INVOKABLE int exec(const QString &sql); // rows affected, -1 on failure
Q_INVOKABLE QVariantList query(const QString &sql);
Q_INVOKABLE QString escape(const QString &s);
private:
static QString makeDbPath();
static void ensureDirExists(const QString &dirPath);
QSqlDatabase m_db;
QString m_connName;
QString m_dbPath;
};

64
src/main.cpp Normal file
View file

@ -0,0 +1,64 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QStandardPaths>
#include "file.h"
#include "mpvaudio.h"
#include "screen.h"
#include "thumbnailprovider.h"
#include "librarydb.h"
#include "mpris.h"
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
app.setApplicationDisplayName("Audiobooks");
app.setApplicationName("Audiobooks");
app.setApplicationVersion("1.0.0");
app.setOrganizationName("ONeill Codesmithing");
app.setOrganizationDomain("oneill.app");
app.setDesktopFileName("Audiobooks.desktop");
setlocale(LC_NUMERIC, "C");
// set up for db:
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir().mkpath(dir);
// QString dbPath = dir + "/library.sqlite3";
// set up for audiobooks:
QStringList docLocations = QStandardPaths::standardLocations(QStandardPaths::DocumentsLocation);
dir = docLocations[0] + QDir::separator() + "Audiobooks";
QDir().mkpath(dir);
// qml time:
QQmlApplicationEngine engine;
engine.addImageProvider("thumbnails", new ThumbnailProvider);
engine.rootContext()->setContextProperty("File", new File());
engine.rootContext()->setContextProperty("MPV", new MpvAudio());
engine.rootContext()->setContextProperty("MPVPreview", new MpvAudio());
engine.rootContext()->setContextProperty("ScreenOps", new Screen());
engine.rootContext()->setContextProperty("LibraryDb", new LibraryDb());
engine.rootContext()->setContextProperty("MPRIS", new Mpris());
QObject::connect(
&engine,
&QQmlApplicationEngine::objectCreationFailed,
&app,
[]() { QCoreApplication::exit(-1); },
Qt::QueuedConnection);
QUrl path = QUrl(QStringLiteral("qrc:/qml/main.qml"));
if( argc > 1 )
path = QUrl(QString::fromLocal8Bit(argv[1]));
engine.load(path);
if (engine.rootObjects().isEmpty())
return -1;
return app.exec();
}

103
src/mpris.cpp Normal file
View file

@ -0,0 +1,103 @@
#include "mpris.h"
#include "mprisadaptor.h"
#include "mprisplayer.h"
#include <QDBusConnection>
#include <QDBusMessage>
#include <QDebug>
MprisContainer::MprisContainer(Mpris *mpris)
: QObject{}
{
m_adaptor = new MprisAdaptor(this);
m_player = new MprisPlayer(this, mpris);
auto bus = QDBusConnection::sessionBus();
bus.registerService("org.mpris.MediaPlayer2.audiobookplayer");
auto success = bus.registerObject(
"/org/mpris/MediaPlayer2",
this, QDBusConnection::ExportAdaptors
/*
QDBusConnection::ExportAllSlots |
QDBusConnection::ExportAllProperties |
QDBusConnection::ExportAllSignals |
QDBusConnection::ExportAdaptors
*/
);
qDebug() << "Registration of MPRIS object:" << success;
}
Mpris::Mpris(QObject *parent)
: QObject{parent}
{
m_root = new MprisContainer(this);
}
Mpris::~Mpris() {
m_root->deleteLater();
m_root = nullptr;
}
void Mpris::updateMetadata(const QVariantMap &metadata)
{
if( !m_root || !m_root->m_player )
return;
m_root->m_player->updateMetadata(metadata);
propertiesChanged();
}
void Mpris::updateStatus(const QString &status)
{
if( !m_root || !m_root->m_player )
return;
m_root->m_player->updateStatus(status);
propertiesChanged();
}
void Mpris::updateRate(double rate)
{
if( !m_root || !m_root->m_player )
return;
return m_root->m_player->updateRate(rate);
}
void Mpris::updateVolume(double volume)
{
if( !m_root || !m_root->m_player )
return;
return m_root->m_player->updateVolume(volume);
}
void Mpris::updatePosition(double position)
{
if( !m_root || !m_root->m_player )
return;
m_root->m_player->updatePosition(position);
propertiesChanged();
}
void Mpris::propertiesChanged() {
if( !m_root || !m_root->m_player )
return;
QDBusConnection bus = QDBusConnection::sessionBus();
QVariantMap changed;
changed["Metadata"] = m_root->m_player->metadata();
changed["PlaybackStatus"] = m_root->m_player->playbackStatus();
auto message = QDBusMessage::createSignal(
"/org/mpris/MediaPlayer2",
"org.freedesktop.DBus.Properties",
"PropertiesChanged"
);
message.setArguments({
"org.mpris.MediaPlayer2.Player",
changed,
QStringList{}
});
bus.send(message);
}

63
src/mpris.h Normal file
View file

@ -0,0 +1,63 @@
#ifndef MPRIS_H
#define MPRIS_H
#include <QObject>
class Mpris;
class MprisAdaptor;
class MprisPlayer;
class MprisContainer : public QObject
{
Q_OBJECT
public:
explicit MprisContainer(Mpris *parent = nullptr);
MprisAdaptor *m_adaptor;
MprisPlayer *m_player;
};
class Mpris : public QObject
{
Q_OBJECT
MprisContainer *m_root;
public:
explicit Mpris(QObject *parent = nullptr);
~Mpris();
void wantPlay() { emit play(); }
void wantPause() { emit pause(); }
void wantPlayPause() { emit playPause(); }
void wantStop() { emit stop(); }
void wantNext() { emit next(); }
void wantPrevious() { emit previous(); }
void wantSeek(double v) { emit seek(v); }
void wantSetPosition(double v) { emit setPosition(v); }
void wantSetRate(double v) { emit setRate(v); }
void wantSetVolume(double v) { emit setVolume(v); }
public slots:
void updateMetadata(const QVariantMap &metadata);
void updateStatus(const QString &status);
void updateRate(double rate);
void updateVolume(double volume);
void updatePosition(double position);
void propertiesChanged();
signals:
void play();
void pause();
void playPause();
void stop();
void next();
void previous();
void seek(double offset);
void setPosition(double position);
void setRate(double rate);
void setVolume(double volume);
};
#endif // MPRIS_H

5
src/mprisadaptor.cpp Normal file
View file

@ -0,0 +1,5 @@
#include "mprisadaptor.h"
MprisAdaptor::MprisAdaptor(QObject *parent)
: QDBusAbstractAdaptor{parent}
{}

38
src/mprisadaptor.h Normal file
View file

@ -0,0 +1,38 @@
#ifndef MPRISADAPTOR_H
#define MPRISADAPTOR_H
#include <QObject>
#include <QVariantMap>
#include <QDBusAbstractAdaptor>
class MprisAdaptor : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2")
Q_PROPERTY(bool CanQuit READ canQuit CONSTANT)
Q_PROPERTY(bool CanRaise READ canRaise CONSTANT)
Q_PROPERTY(bool HasTrackList READ hasTrackList CONSTANT)
Q_PROPERTY(QString Identity READ identity CONSTANT)
Q_PROPERTY(QString DesktopEntry READ desktopEntry CONSTANT)
Q_PROPERTY(QStringList SupportedUriSchemes READ supportedUriSchemes CONSTANT)
Q_PROPERTY(QStringList SupportedMimeTypes READ supportedMimeTypes CONSTANT)
public:
explicit MprisAdaptor(QObject *parent = nullptr);
bool canQuit() const { return true; }
bool canRaise() const { return true; }
bool hasTrackList() const { return false; }
QString identity() const { return QStringLiteral("Audiobook Player"); }
QString desktopEntry() const { return QStringLiteral("Audiobooks"); }
QStringList supportedUriSchemes() const { return { "file" }; }
QStringList supportedMimeTypes() const { return { "audio/mpeg", "audio/mp4", "audio/x-m4b" }; }
public slots:
void Quit() {} // TODO
void Raise() {} // TODO
};
#endif // MPRISADAPTOR_H

134
src/mprisplayer.cpp Normal file
View file

@ -0,0 +1,134 @@
#include "mprisplayer.h"
#include "mpris.h"
#include <QDebug>
#include <QUrl>
MprisPlayer::MprisPlayer(QObject *parent, Mpris *mpris)
: QDBusAbstractAdaptor{parent},
m_mpris{mpris}
{
}
QString MprisPlayer::playbackStatus() const
{
return m_status;
}
void MprisPlayer::updateMetadata(const QVariantMap &metadata)
{
m_bookid = metadata["id"].toInt();
m_chapter = metadata["chapter"].toInt();
m_chapterCount = metadata["chapterCount"].toInt();
m_trackId = QDBusObjectPath(QString("/org/mpris/MediaPlayer2/track/%1/%2").arg(m_bookid).arg(m_chapter));
m_metadata = {
{ "mpris:trackid", QVariant::fromValue(m_trackId) },
{ "xesam:title", metadata["chapterTitle"].toString() },
{ "xesam:artist", QStringList{ metadata["artist"].toString() } },
{ "xesam:album", metadata["title"].toString() },
{ "mpris:length", qlonglong(metadata["duration"].toUInt() * 1000000) },
{ "xesam:genre", QStringList{ "Audiobook" } }
};
if( metadata.contains("artUrl") )
m_metadata["mpris:artUrl"] = QUrl(metadata.value("artUrl").toString()).toString(QUrl::FullyEncoded);
//m_metadata["xesam:artUrl"] = metadata.value("artUrl").toString();
qDebug() << m_metadata;
emit metadataChanged();
}
void MprisPlayer::updateStatus(const QString &status)
{
if( m_status == status )
return;
if( status == "Playing" || status == "Paused" || status == "Stopped" )
{
m_status = status;
emit playbackStatusChanged();
return;
}
}
void MprisPlayer::updateRate(double rate)
{
if( m_rate == rate )
return;
m_rate = rate;
emit rateChanged();
}
void MprisPlayer::updateVolume(double volume)
{
if( m_volume == volume )
return;
m_volume = volume;
emit volumeChanged();
}
void MprisPlayer::updatePosition(double position)
{
qlonglong newpos = 1000000 * position;
if( newpos == m_positionUs )
return;
m_positionUs = newpos;
emit positionChanged();
emit Seeked(m_positionUs);
}
void MprisPlayer::Play()
{
m_mpris->wantPlay();
}
void MprisPlayer::Pause()
{
m_mpris->wantPause();
}
void MprisPlayer::PlayPause()
{
m_mpris->wantPlayPause();
}
void MprisPlayer::Stop()
{
m_mpris->wantStop();
}
void MprisPlayer::Next()
{
m_mpris->wantNext();
}
void MprisPlayer::Previous()
{
m_mpris->wantPrevious();
}
void MprisPlayer::Seek(qlonglong offsetUs)
{
m_mpris->wantSeek(offsetUs * 0.000001);
}
void MprisPlayer::SetPosition(const QDBusObjectPath &p, qlonglong posUs)
{
if( p != m_trackId )
return;
m_mpris->wantSetPosition(posUs * 0.000001);
}
void MprisPlayer::setRate(double r)
{
m_mpris->wantSetRate(r);
}
void MprisPlayer::setVolume(double v)
{
m_mpris->wantSetVolume(v);
}

93
src/mprisplayer.h Normal file
View file

@ -0,0 +1,93 @@
#ifndef MPRISPLAYER_H
#define MPRISPLAYER_H
#include <QObject>
#include <QVariantMap>
#include <QDBusObjectPath>
#include <QDBusAbstractAdaptor>
class Mpris;
class MprisPlayer : public QDBusAbstractAdaptor
{
Q_OBJECT
Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2.Player")
Q_PROPERTY(QString PlaybackStatus READ playbackStatus NOTIFY playbackStatusChanged)
Q_PROPERTY(QString LoopStatus READ loopStatus CONSTANT)
Q_PROPERTY(double Rate READ rate WRITE setRate NOTIFY rateChanged)
Q_PROPERTY(bool Shuffle READ shuffle CONSTANT)
Q_PROPERTY(QVariantMap Metadata READ metadata NOTIFY metadataChanged)
Q_PROPERTY(double Volume READ volume WRITE setVolume NOTIFY volumeChanged)
Q_PROPERTY(qlonglong Position READ position NOTIFY positionChanged)
Q_PROPERTY(bool CanGoNext READ canGoNext NOTIFY canGoNextChanged)
Q_PROPERTY(bool CanGoPrevious READ canGoPrevious NOTIFY canGoPreviousChanged)
Q_PROPERTY(bool CanPlay READ canPlay CONSTANT)
Q_PROPERTY(bool CanPause READ canPause CONSTANT)
Q_PROPERTY(bool CanSeek READ canSeek CONSTANT)
Q_PROPERTY(bool CanControl READ canControl CONSTANT)
public:
explicit MprisPlayer(QObject *parent, Mpris *mpris);
QString playbackStatus() const;
QString loopStatus() const { return QStringLiteral("None"); }
double rate() const { return m_rate; }
bool shuffle() const { return false; }
QVariantMap metadata() const { return m_metadata; }
double volume() const { return m_volume; }
qlonglong position() const { return m_positionUs; }
bool canGoNext() const { return true; }
bool canGoPrevious() const { return true; }
bool canPlay() const { return true; }
bool canPause() const { return true; }
bool canSeek() const { return true; }
bool canControl() const { return true; }
// For Mpris class to fiddle with:
void updateMetadata(const QVariantMap &metadata);
void updateStatus(const QString &status);
void updateRate(double rate);
void updateVolume(double volume);
void updatePosition(double position);
public slots:
void Play();
void Pause();
void PlayPause();
void Stop();
void Next();
void Previous();
void Seek(qlonglong offsetUs);
void SetPosition(const QDBusObjectPath &, qlonglong posUs);
void setRate(double r);
void setVolume(double v);
signals:
void Seeked(qlonglong Position);
void playbackStatusChanged();
void metadataChanged();
void rateChanged();
void volumeChanged();
void positionChanged();
void canGoNextChanged();
void canGoPreviousChanged();
private:
Mpris *m_mpris;
QString m_status = "Stopped";
double m_rate = 1.0;
double m_volume = 1.0;
qlonglong m_positionUs = 0;
QVariantMap m_metadata;
int m_bookid;
int m_chapter;
int m_chapterCount;
QDBusObjectPath m_trackId;
};
#endif // MPRISPLAYER_H

407
src/mpvaudio.cpp Normal file
View file

@ -0,0 +1,407 @@
#include "mpvaudio.h"
#include <QtGlobal>
static constexpr int PROP_TIME_POS = 1;
static constexpr int PROP_DURATION = 2;
static constexpr int PROP_PAUSE = 3;
static constexpr int PROP_SPEED = 4;
static constexpr int PROP_VOLUME = 5;
static constexpr int PROP_MEDIA_TITLE = 6;
static constexpr int PROP_CORE_IDLE = 7;
//static constexpr int PROP_MEDIA_ARTIST = 8;
static constexpr int PROP_METADATA = 20; // "metadata" (map)
static constexpr int PROP_CHAPTER_LIST = 21; // "chapter-list" (array)
static constexpr int PROP_CHAPTER = 22; // "chapter" (int)
static constexpr int PROP_CHAPTER_META = 23; // "chapter-metadata" (map)
MpvAudio::MpvAudio(QObject *parent) : QObject(parent) {
initMpv();
}
MpvAudio::~MpvAudio() {
if (m_notifier) {
m_notifier->setEnabled(false);
delete m_notifier;
m_notifier = nullptr;
}
if (m_mpv) {
mpv_set_wakeup_callback(m_mpv, nullptr, nullptr);
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
}
}
void MpvAudio::initMpv() {
m_mpv = mpv_create();
if (!m_mpv) {
emit errorOccurred(QStringLiteral("mpv_create() failed"));
return;
}
// Audio-only
mpv_set_option_string(m_mpv, "video", "no");
mpv_set_option_string(m_mpv, "vid", "no");
mpv_set_option_string(m_mpv, "vo", "null");
// Audiobook niceties
mpv_set_option_string(m_mpv, "audio-pitch-correction", "yes");
mpv_set_option_string(m_mpv, "gapless-audio", "yes");
mpv_set_option_string(m_mpv, "keep-open", "yes");
// Reduce log spam
mpv_set_option_string(m_mpv, "terminal", "yes");
mpv_set_option_string(m_mpv, "msg-level", "all=warn");
if (mpv_initialize(m_mpv) < 0) {
emit errorOccurred(QStringLiteral("mpv_initialize() failed"));
mpv_terminate_destroy(m_mpv);
m_mpv = nullptr;
return;
}
mpv_set_wakeup_callback(m_mpv, &MpvAudio::mpvWakeup, this);
/*
int fd = mpv_get_wakeup_pipe(m_mpv);
if (fd >= 0) {
m_notifier = new QSocketNotifier(fd, QSocketNotifier::Read, this);
connect(m_notifier, &QSocketNotifier::activated, this, &MpvAudio::drainMpvEvents);
}
*/
observeProperties();
}
void MpvAudio::observeProperties() {
if (!m_mpv) return;
mpv_observe_property(m_mpv, PROP_TIME_POS, "time-pos", MPV_FORMAT_DOUBLE);
mpv_observe_property(m_mpv, PROP_DURATION, "duration", MPV_FORMAT_DOUBLE);
mpv_observe_property(m_mpv, PROP_PAUSE, "pause", MPV_FORMAT_FLAG);
mpv_observe_property(m_mpv, PROP_SPEED, "speed", MPV_FORMAT_DOUBLE);
mpv_observe_property(m_mpv, PROP_VOLUME, "volume", MPV_FORMAT_DOUBLE);
mpv_observe_property(m_mpv, PROP_MEDIA_TITLE, "media-title", MPV_FORMAT_STRING);
//mpv_observe_property(m_mpv, PROP_MEDIA_ARTIST,"media-artist", MPV_FORMAT_STRING);
mpv_observe_property(m_mpv, PROP_CORE_IDLE, "core-idle", MPV_FORMAT_FLAG);
mpv_observe_property(m_mpv, PROP_METADATA, "metadata", MPV_FORMAT_NODE);
mpv_observe_property(m_mpv, PROP_CHAPTER_LIST, "chapter-list", MPV_FORMAT_NODE);
mpv_observe_property(m_mpv, PROP_CHAPTER, "chapter", MPV_FORMAT_INT64);
mpv_observe_property(m_mpv, PROP_CHAPTER_META, "chapter-metadata", MPV_FORMAT_NODE);
}
void MpvAudio::mpvWakeup(void *ctx) {
auto *self = static_cast<MpvAudio*>(ctx);
if (!self) return;
QMetaObject::invokeMethod(self, "drainMpvEvents", Qt::QueuedConnection);
}
void MpvAudio::drainMpvEvents() {
if (!m_mpv) return;
while (true) {
mpv_event *ev = mpv_wait_event(m_mpv, 0);
if (!ev || ev->event_id == MPV_EVENT_NONE) break;
handleEvent(ev);
}
}
static inline bool fuzzySame(double a, double b) {
return qFuzzyCompare(a + 1.0, b + 1.0);
}
static inline bool notableTimeGap(double a, double b) {
if( qAbs( a-b ) < 0.25 )
return true;
return false;
}
QVariantMap MpvAudio::pullNodeMap(const char *prop) {
QVariantMap out;
if (!m_mpv) return out;
mpv_node n{};
if (mpv_get_property(m_mpv, prop, MPV_FORMAT_NODE, &n) >= 0) {
out = nodeToMap(n);
mpv_free_node_contents(&n);
}
return out;
}
QVariantList MpvAudio::pullNodeList(const char *prop) {
QVariantList out;
if (!m_mpv) return out;
mpv_node n{};
if (mpv_get_property(m_mpv, prop, MPV_FORMAT_NODE, &n) >= 0) {
out = nodeToList(n);
mpv_free_node_contents(&n);
}
return out;
}
void MpvAudio::handleEvent(mpv_event *ev) {
switch( ev->event_id ) {
case MPV_EVENT_START_FILE: {
if (m_loaded) { m_loaded = false; emit loadedChanged(); }
break;
}
case MPV_EVENT_FILE_LOADED: {
// Force refresh: mpv_observe_property is fine, but this guarantees immediate population.
// (Implement pullNodeProperty below.)
m_metadata = pullNodeMap("metadata");
emit metadataChanged();
m_chapters = pullNodeList("chapter-list");
emit chaptersChanged();
m_chapterMetadata = pullNodeMap("chapter-metadata");
emit chapterMetadataChanged();
if (!m_loaded) { m_loaded = true; emit loadedChanged(); }
break;
}
default:
break;
}
if (ev->event_id != MPV_EVENT_PROPERTY_CHANGE) return;
auto *p = static_cast<mpv_event_property*>(ev->data);
if (!p) return;
switch (ev->reply_userdata) {
case PROP_TIME_POS: {
if (!p->data) break;
double v = *static_cast<double*>(p->data);
if (!notableTimeGap(m_position, v)) { m_position = v; emit positionChanged(); }
break;
}
case PROP_DURATION: {
if (!p->data) break;
double v = *static_cast<double*>(p->data);
if (!fuzzySame(m_duration, v)) { m_duration = v; emit durationChanged(); }
break;
}
case PROP_PAUSE: {
if (!p->data) break;
bool v = (*static_cast<int*>(p->data)) != 0;
if (m_paused != v) { m_paused = v; emit pausedChanged(); }
break;
}
case PROP_SPEED: {
if (!p->data) break;
double v = *static_cast<double*>(p->data);
if (!fuzzySame(m_speed, v)) { m_speed = v; emit speedChanged(); }
break;
}
case PROP_VOLUME: {
if (!p->data) break;
double v = *static_cast<double*>(p->data);
if (!fuzzySame(m_volume, v)) { m_volume = v; emit volumeChanged(); }
break;
}
case PROP_MEDIA_TITLE: {
const char *s = p->data ? *static_cast<const char**>(p->data) : nullptr;
QString v = s ? QString::fromUtf8(s) : QString();
if (m_mediaTitle != v) { m_mediaTitle = v; emit mediaTitleChanged(); }
break;
}
/*
case PROP_MEDIA_ARTIST: {
const char *s = p->data ? *static_cast<const char**>(p->data) : nullptr;
QString v = s ? QString::fromUtf8(s) : QString();
if (m_mediaArtist != v) { m_mediaArtist = v; emit mediaArtistChanged(); }
break;
}
*/
case PROP_CORE_IDLE: {
if (!p->data) break;
bool idle = (*static_cast<int*>(p->data)) != 0;
bool newPlaying = !idle && !m_paused;
if (m_playing != newPlaying) { m_playing = newPlaying; emit playingChanged(); }
break;
}
case PROP_METADATA: {
if (!p->data) { // cleared
if (!m_metadata.isEmpty()) { m_metadata.clear(); emit metadataChanged(); }
break;
}
const mpv_node &n = *static_cast<mpv_node*>(p->data);
QVariantMap v = nodeToMap(n);
if (m_metadata != v) { m_metadata = std::move(v); emit metadataChanged(); }
break;
}
case PROP_CHAPTER_LIST: {
if (!p->data) {
if (!m_chapters.isEmpty()) { m_chapters.clear(); emit chaptersChanged(); }
break;
}
const mpv_node &n = *static_cast<mpv_node*>(p->data);
QVariantList v = nodeToList(n);
if (m_chapters != v) { m_chapters = std::move(v); emit chaptersChanged(); }
break;
}
case PROP_CHAPTER: {
if (!p->data) break;
qint64 v = *static_cast<qint64*>(p->data);
int vi = (v < 0) ? -1 : int(v);
if (m_currentChapter != vi) { m_currentChapter = vi; emit currentChapterChanged(); }
break;
}
case PROP_CHAPTER_META: {
if (!p->data) {
if (!m_chapterMetadata.isEmpty()) { m_chapterMetadata.clear(); emit chapterMetadataChanged(); }
break;
}
const mpv_node &n = *static_cast<mpv_node*>(p->data);
QVariantMap v = nodeToMap(n);
if (m_chapterMetadata != v) { m_chapterMetadata = std::move(v); emit chapterMetadataChanged(); }
break;
}
default:
break;
}
}
void MpvAudio::setPropBool(const char *name, bool v) {
if (!m_mpv) return;
int flag = v ? 1 : 0;
if (mpv_set_property(m_mpv, name, MPV_FORMAT_FLAG, &flag) < 0)
emit errorOccurred(QStringLiteral("Failed to set %1").arg(QString::fromLatin1(name)));
}
void MpvAudio::setPropDouble(const char *name, double v) {
if (!m_mpv) return;
if (mpv_set_property(m_mpv, name, MPV_FORMAT_DOUBLE, &v) < 0)
emit errorOccurred(QStringLiteral("Failed to set %1").arg(QString::fromLatin1(name)));
}
void MpvAudio::setPropInt64(const char *name, qint64 v) {
if (!m_mpv) return;
if (mpv_set_property(m_mpv, name, MPV_FORMAT_INT64, &v) < 0)
emit errorOccurred(QStringLiteral("Failed to set %1").arg(QString::fromLatin1(name)));
}
// === node conversion ===
QVariant MpvAudio::nodeToVariant(const mpv_node &n) {
switch (n.format) {
case MPV_FORMAT_STRING: return n.u.string ? QString::fromUtf8(n.u.string) : QVariant();
case MPV_FORMAT_FLAG: return bool(n.u.flag);
case MPV_FORMAT_INT64: return qint64(n.u.int64);
case MPV_FORMAT_DOUBLE: return double(n.u.double_);
case MPV_FORMAT_NODE_MAP: return nodeToMap(n);
case MPV_FORMAT_NODE_ARRAY:return nodeToList(n);
case MPV_FORMAT_NONE:
default: return QVariant();
}
}
QVariantMap MpvAudio::nodeToMap(const mpv_node &n) {
QVariantMap out;
if (n.format != MPV_FORMAT_NODE_MAP) return out;
const mpv_node_list *lst = n.u.list;
if (!lst) return out;
for (int i = 0; i < lst->num; ++i) {
const char *k = lst->keys ? lst->keys[i] : nullptr;
if (!k) continue;
out.insert(QString::fromUtf8(k), nodeToVariant(lst->values[i]));
}
return out;
}
QVariantList MpvAudio::nodeToList(const mpv_node &n) {
QVariantList out;
if (n.format != MPV_FORMAT_NODE_ARRAY) return out;
const mpv_node_list *lst = n.u.list;
if (!lst) return out;
out.reserve(lst->num);
for (int i = 0; i < lst->num; ++i)
out.push_back(nodeToVariant(lst->values[i]));
return out;
}
/*** Controls ***/
void MpvAudio::load(const QUrl &url) {
if (!m_mpv) return;
const QString s = url.isLocalFile() ? url.toLocalFile() : url.toString();
if (s.isEmpty()) { emit errorOccurred(QStringLiteral("Empty URL")); return; }
QByteArray u8 = s.toUtf8();
const char *cmd[] = { "loadfile", u8.constData(), "replace", nullptr };
int r = mpv_command(m_mpv, cmd);
if (r < 0) emit errorOccurred(QStringLiteral("loadfile failed: %1").arg(QString::fromUtf8(mpv_error_string(r))));
//setPaused(false);
m_source = url;
emit sourceChanged();
}
void MpvAudio::play() { setPaused(false); }
void MpvAudio::pause() { setPaused(true); }
void MpvAudio::togglePause() { setPaused(!m_paused); }
void MpvAudio::stop() {
if (!m_mpv) return;
const char *cmd[] = { "stop", nullptr };
mpv_command(m_mpv, cmd);
}
void MpvAudio::seek(double seconds, bool absolute) {
if (!m_mpv) return;
QByteArray t = QByteArray::number(seconds, 'f', 3);
const char *mode = absolute ? "absolute" : "relative";
const char *cmd[] = { "seek", t.constData(), mode, nullptr };
int r = mpv_command(m_mpv, cmd);
if (r < 0) emit errorOccurred(QStringLiteral("seek failed: %1").arg(QString::fromUtf8(mpv_error_string(r))));
}
void MpvAudio::seekBy(double deltaSeconds) { seek(deltaSeconds, false); }
void MpvAudio::setLoop(bool enabled) {
if( !m_mpv ) return;
mpv_set_option_string(m_mpv, "loop-file", enabled ? "inf" : "no");
}
void MpvAudio::setPaused(bool v) {
if (m_paused == v) return;
setPropBool("pause", v);
}
void MpvAudio::setPosition(double seconds) { seek(seconds, true); }
void MpvAudio::setSpeed(double v) {
if (v < 0.25) v = 0.25;
if (v > 4.0) v = 4.0;
setPropDouble("speed", v);
}
void MpvAudio::setVolume(double v) {
if (v < 0.0) v = 0.0;
if (v > 130.0) v = 130.0;
setPropDouble("volume", v);
}
void MpvAudio::setCurrentChapter(int idx) {
if (idx < -1) idx = -1;
setPropInt64("chapter", idx);
}
void MpvAudio::goToChapter(int index) {
setCurrentChapter(index);
}

152
src/mpvaudio.h Normal file
View file

@ -0,0 +1,152 @@
#ifndef MPVAUDIO_H
#define MPVAUDIO_H
#include <QObject>
#include <QUrl>
#include <QVariant>
#include <QVariantMap>
#include <QVariantList>
#include <QSocketNotifier>
#include <mpv/client.h>
class MpvAudio : public QObject {
Q_OBJECT
Q_PROPERTY(QUrl source READ source WRITE load NOTIFY sourceChanged)
Q_PROPERTY(bool loaded READ loaded NOTIFY loadedChanged)
Q_PROPERTY(bool playing READ playing NOTIFY playingChanged)
Q_PROPERTY(bool paused READ paused WRITE setPaused NOTIFY pausedChanged)
Q_PROPERTY(double position READ position WRITE setPosition NOTIFY positionChanged)
Q_PROPERTY(double duration READ duration NOTIFY durationChanged)
Q_PROPERTY(double speed READ speed WRITE setSpeed NOTIFY speedChanged)
Q_PROPERTY(double volume READ volume WRITE setVolume NOTIFY volumeChanged)
Q_PROPERTY(QString mediaTitle READ mediaTitle NOTIFY mediaTitleChanged)
Q_PROPERTY(QString mediaArtist READ mediaArtist NOTIFY mediaArtistChanged)
Q_PROPERTY(QVariantMap metadata READ metadata NOTIFY metadataChanged)
Q_PROPERTY(QVariantList chapters READ chapters NOTIFY chaptersChanged)
Q_PROPERTY(int currentChapter READ currentChapter WRITE setCurrentChapter NOTIFY currentChapterChanged)
Q_PROPERTY(int chapterCount READ chapterCount NOTIFY chaptersChanged)
Q_PROPERTY(QVariantMap chapterMetadata READ chapterMetadata NOTIFY chapterMetadataChanged)
public:
explicit MpvAudio(QObject *parent = nullptr);
~MpvAudio() override;
Q_INVOKABLE void load(const QUrl &url);
Q_INVOKABLE void play();
Q_INVOKABLE void pause();
Q_INVOKABLE void togglePause();
Q_INVOKABLE void stop();
Q_INVOKABLE void seek(double seconds, bool absolute = true);
Q_INVOKABLE void seekBy(double deltaSeconds);
Q_INVOKABLE void setLoop(bool enabled);
Q_INVOKABLE void goToChapter(int index);
QUrl source() const { return m_source; }
bool loaded() const { return m_loaded; }
bool playing() const { return m_playing; }
bool paused() const { return m_paused; }
void setPaused(bool v);
double position() const { return m_position; }
void setPosition(double seconds);
double duration() const { return m_duration; }
double speed() const { return m_speed; }
void setSpeed(double v);
double volume() const { return m_volume; }
void setVolume(double v);
QString mediaTitle() const { return m_mediaTitle; }
QString mediaArtist() const { return m_mediaArtist; }
QVariantMap metadata() const { return m_metadata; }
QVariantList chapters() const { return m_chapters; }
int currentChapter() const { return m_currentChapter; }
void setCurrentChapter(int idx);
int chapterCount() const { return m_chapters.size(); }
QVariantMap chapterMetadata() const { return m_chapterMetadata; }
signals:
void sourceChanged();
void loadedChanged();
void playingChanged();
void pausedChanged();
void positionChanged();
void durationChanged();
void speedChanged();
void volumeChanged();
void mediaTitleChanged();
void mediaArtistChanged();
void metadataChanged();
void chaptersChanged();
void currentChapterChanged();
void chapterMetadataChanged();
void errorOccurred(QString message);
private slots:
void drainMpvEvents();
private:
static void mpvWakeup(void *ctx);
void initMpv();
void observeProperties();
void handleEvent(mpv_event *ev);
void setPropBool(const char *name, bool v);
void setPropDouble(const char *name, double v);
void setPropInt64(const char *name, qint64 v);
static QVariant nodeToVariant(const mpv_node &n);
static QVariantMap nodeToMap(const mpv_node &n);
static QVariantList nodeToList(const mpv_node &n);
QVariantMap pullNodeMap(const char *prop);
QVariantList pullNodeList(const char *prop);
mpv_handle *m_mpv = nullptr;
QSocketNotifier *m_notifier = nullptr;
QUrl m_source;
bool m_loaded = false;
bool m_playing = false;
bool m_paused = true;
double m_position = 0.0;
double m_duration = 0.0;
double m_speed = 1.0;
double m_volume = 100.0;
QString m_mediaTitle;
QString m_mediaArtist;
QVariantMap m_metadata;
QVariantList m_chapters;
int m_currentChapter = -1;
QVariantMap m_chapterMetadata;
};
#endif // MPVAUDIO_H

68
src/screen.cpp Normal file
View file

@ -0,0 +1,68 @@
#include "screen.h"
Screen::Screen(QObject *parent)
: QObject{parent}
{}
void Screen::setInhibited(bool on, const QString &reason)
{
if(on == m_inhibited)
return;
if(on)
enableInhibit(reason);
else
disableInhibit();
}
void Screen::enableInhibit(const QString &reason)
{
// Try org.freedesktop.ScreenSaver first
if (tryInhibit("org.freedesktop.ScreenSaver",
"/ScreenSaver",
"org.freedesktop.ScreenSaver",
reason))
return;
// Fallback: KDE power management
tryInhibit("org.freedesktop.PowerManagement",
"/org/freedesktop/PowerManagement/Inhibit",
"org.freedesktop.PowerManagement.Inhibit",
reason);
}
bool Screen::tryInhibit(const QString &service,
const QString &path,
const QString &ifaceName,
const QString &reason)
{
QDBusInterface iface(service, path, ifaceName, QDBusConnection::sessionBus());
if (!iface.isValid())
return false;
QDBusReply<uint> reply = iface.call("Inhibit",
qApp->applicationName(),
reason);
if (!reply.isValid())
return false;
m_cookie = reply.value();
m_service = service;
m_path = path;
m_iface = ifaceName;
m_inhibited = true;
return true;
}
void Screen::disableInhibit()
{
if (!m_inhibited || m_cookie == 0)
return;
QDBusInterface iface(m_service, m_path, m_iface, QDBusConnection::sessionBus());
if (iface.isValid())
iface.call("UnInhibit", m_cookie);
m_cookie = 0;
m_inhibited = false;
}

36
src/screen.h Normal file
View file

@ -0,0 +1,36 @@
#ifndef SCREEN_H
#define SCREEN_H
#include <QGuiApplication>
#include <QObject>
#include <QDBusInterface>
#include <QDBusReply>
class Screen : public QObject
{
Q_OBJECT
public:
explicit Screen(QObject *parent = nullptr);
Q_INVOKABLE void setInhibited(bool on, const QString &reason={});
private:
bool m_inhibited = false;
uint m_cookie = 0;
QString m_service;
QString m_path;
QString m_iface;
void enableInhibit(const QString &reason);
bool tryInhibit(const QString &service,
const QString &path,
const QString &ifaceName,
const QString &reason);
void disableInhibit();
};
#endif // SCREEN_H

48
src/thumbnailprovider.cpp Normal file
View file

@ -0,0 +1,48 @@
#include "thumbnailprovider.h"
#include <QDebug>
#include <QQuickAsyncImageProvider>
#include <KIO/PreviewJob>
#include <KFileItem>
AsyncThumbnailResponse::AsyncThumbnailResponse(const QString &id, const QSize &requestedSize)
{
m_id = id;
m_size = requestedSize;
fetch();
}
QQuickTextureFactory *AsyncThumbnailResponse::textureFactory() const
{
return QQuickTextureFactory::textureFactoryForImage(m_image);
}
void AsyncThumbnailResponse::fetch() {
const QUrl url = QUrl::fromPercentEncoding(m_id.toUtf8());
const QSize thumbSize = m_size.isValid() ? m_size : QSize(256, 256);
KFileItem item(url, KFileItem::NormalMimeTypeDetermination);
QStringList plugins = KIO::PreviewJob::availablePlugins();
auto job = KIO::filePreview({ item }, thumbSize, &plugins);
QObject::connect(job, &KIO::PreviewJob::gotPreview, this,
[this](const KFileItem &, const QPixmap &pixmap) {
m_image = pixmap.toImage();
//qDebug() << "Done:" << m_image.size();
emit finished();
});
QObject::connect(job, &KIO::PreviewJob::failed, this,
[this](const KFileItem &item) {
qDebug() << QStringLiteral("Failed to load thumbnail for %1").arg(item.url().toString());
//m_error = QStringLiteral("Failed to load thumbnail for %1").arg(item.url().toString());
emit finished();
});
}
QQuickImageResponse *ThumbnailProvider::requestImageResponse(const QString &id, const QSize &requestedSize)
{
AsyncThumbnailResponse *res = new AsyncThumbnailResponse(id, requestedSize);
return res;
}

28
src/thumbnailprovider.h Normal file
View file

@ -0,0 +1,28 @@
#pragma once
#include <QImage>
#include <QMimeDatabase>
#include <QQuickAsyncImageProvider>
#include <QQuickTextureFactory>
#include <QString>
class AsyncThumbnailResponse : public QQuickImageResponse {
public:
AsyncThumbnailResponse(const QString &id, const QSize &requestedSize);
QQuickTextureFactory *textureFactory() const override;
void fetch();
QString m_id;
QImage m_image;
QSize m_size;
QMimeDatabase m_mimedb;
};
class ThumbnailProvider : public QQuickAsyncImageProvider {
public:
QQuickImageResponse *requestImageResponse(const QString &id, const QSize &requestedSize) override;
};