summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTimotej Lazar <timotej.lazar@araneo.si>2021-06-14 19:09:53 +0200
committerTimotej Lazar <timotej.lazar@araneo.si>2021-06-14 19:13:14 +0200
commit8d57bfb1aef3b71557bc408154ee028751fd688e (patch)
tree22ee6eaa22a3cfc7dfd4f938be16a6e28d7eaf1f
First commit
There was history before but now there is no more.
-rw-r--r--.gitignore2
-rw-r--r--Event.qml109
-rw-r--r--Events.qml155
-rw-r--r--Fields/Bool.qml27
-rw-r--r--Fields/Enum.qml59
-rw-r--r--Fields/Text.qml50
-rw-r--r--Fields/TextArea.qml54
-rw-r--r--Filter.qml34
-rw-r--r--README.md31
-rw-r--r--Sidebar.qml245
-rw-r--r--Tags.qml47
-rw-r--r--UNLICENSE24
-rw-r--r--Video.qml150
-rw-r--r--Volume.qml30
-rw-r--r--fuzbal.pro17
-rw-r--r--icons.qrc15
-rw-r--r--icons/breeze/LICENSE165
-rw-r--r--icons/breeze/actions/symbolic/document-open.svg13
-rw-r--r--icons/breeze/actions/symbolic/document-save.svg13
-rw-r--r--icons/breeze/actions/symbolic/edit-clear.svg13
-rw-r--r--icons/breeze/actions/symbolic/edit-delete.svg14
-rw-r--r--icons/breeze/actions/symbolic/media-playback-pause.svg8
-rw-r--r--icons/breeze/index.theme580
-rw-r--r--icons/breeze/status/symbolic/audio-volume-high.svg13
-rw-r--r--icons/breeze/status/symbolic/audio-volume-low.svg17
-rw-r--r--icons/breeze/status/symbolic/audio-volume-medium.svg17
-rw-r--r--icons/breeze/status/symbolic/audio-volume-muted.svg5
-rw-r--r--io.h37
-rw-r--r--main.cpp54
-rw-r--r--main.qml40
-rw-r--r--main.qrc20
-rw-r--r--qtquickcontrols2.conf2
-rw-r--r--tags.json56
-rw-r--r--translations/fuzbal_sl.ts85
-rw-r--r--util.js30
35 files changed, 2231 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b539482
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+build/
+*.qmlc
diff --git a/Event.qml b/Event.qml
new file mode 100644
index 0000000..38d3eeb
--- /dev/null
+++ b/Event.qml
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+import QtQuick.Layouts 1.6
+
+import 'util.js' as Util
+
+// This is the delegate for event list items.
+Pane {
+ id: control
+
+ property int time
+ property alias tag: tag.text
+ property alias fields: inputs.model
+ property bool editing: false
+
+ signal remove
+
+ clip: true
+ height: visible ? stuff.height + 2*padding : 0 // TODO fix filtering and remove this
+ padding: 2
+
+ // set to current value or default
+ function reset() {
+ for (var i = 0; i < fields.count; i++) {
+ const child = inputs.itemAt(i)
+ if (child && child.item)
+ child.item.set(fields.get(i).value)
+ }
+ }
+
+ function store() {
+ for (var i = 0; i < fields.count; i++)
+ fields.setProperty(i, 'value', inputs.itemAt(i).item.value)
+ }
+
+ // Pass keys to each field input in order.
+ Keys.forwardTo: Array.from({ length: inputs.count }, (_, i) => inputs.itemAt(i).item)
+
+ Behavior on height { NumberAnimation { duration: 50 } }
+
+ ColumnLayout {
+ id: stuff
+ anchors { left: parent.left; right: parent.right; margins: 5 }
+
+ RowLayout {
+ Label {
+ text: new Date(time).toISOString().substr(12, 9)
+ font.pixelSize: 10
+ Layout.alignment: Qt.AlignBaseline
+ }
+ Label {
+ id: tag
+ font.weight: Font.DemiBold
+ Layout.alignment: Qt.AlignBaseline
+ }
+ Label {
+ text: {
+ var str = ''
+ for (var i = 0; i < fields.count; i++) {
+ const field = fields.get(i)
+ if (field.value && field.type !== 'TextArea')
+ str += (field.type === 'Bool' ? field.name : field.value) + ' '
+ }
+ return str
+ }
+ elide: Text.ElideRight
+ textFormat: Text.PlainText
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignBaseline
+ }
+ }
+
+ // Event‐specific inputs.
+ GridLayout {
+ id: fieldset
+
+ flow: GridLayout.TopToBottom
+ rows: inputs.count
+
+ columnSpacing: 10
+ visible: editing
+
+ // Labels.
+ Repeater {
+ model: inputs.model
+ delegate: Label {
+ text: Util.addShortcut(model.name, model.key)
+ Layout.alignment: Qt.AlignRight
+ }
+ }
+
+ // Inputs.
+ Repeater {
+ id: inputs
+ delegate: Loader {
+ source: 'qrc:/Fields/' + model.type + '.qml'
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+ Binding {
+ target: item; property: 'definition'
+ value: model
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Events.qml b/Events.qml
new file mode 100644
index 0000000..8853f90
--- /dev/null
+++ b/Events.qml
@@ -0,0 +1,155 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+import QtQuick.Layouts 1.6
+import QtQml.Models 2.1
+
+import 'util.js' as Util
+
+ListView {
+ id: control
+
+ property bool editing: false
+ property var tags: []
+
+ signal changed
+
+ clip: true
+ focus: true
+ keyNavigationEnabled: true
+ highlightMoveDuration: 0
+ highlightResizeDuration: 0
+ ScrollBar.vertical: ScrollBar { anchors.right: parent.right }
+
+ // Create a new blank event, insert it and start editing.
+ function create(time, tag, fields) {
+ const index = Util.find(list, 'time', time)
+ list.insert(index, {
+ 'time': time,
+ 'tag': tag,
+ 'fields': fields,
+ })
+ currentIndex = index
+ if (fields.length > 0)
+ editing = true
+ changed()
+ }
+
+ function clear() {
+ list.clear()
+ }
+
+ function load(json) {
+ // Return list of fields for the given tag.
+ function getFields(name) {
+ for (var i = 0; i < tags.length; i++)
+ if (tags[i].tag === name)
+ return tags[i].fields
+ return []
+ }
+
+ for (var i = 0; i < json.length; i++) {
+ const event = json[i]
+ var fields = getFields(event.tag)
+ for (var j = 0; j < fields.length; j++)
+ fields[j].value = event.fields[fields[j].name]
+ list.append({ 'time': event.time, 'tag': event.tag, 'fields': fields })
+ }
+ forceActiveFocus()
+ }
+
+ function save() {
+ var data = []
+ for (var i = 0; i < list.count; i++) {
+ const event = list.get(i)
+ var fields = {}
+ for (var j = 0; j < event.fields.count; j++) {
+ const field = event.fields.get(j)
+ fields[field.name] = field.value
+ }
+ data.push({ 'time': event.time, 'tag': event.tag, 'fields': fields })
+ }
+ return data
+ }
+
+ onCurrentIndexChanged: editing = false
+
+ Keys.onPressed: {
+ switch (event.key) {
+ case Qt.Key_Enter:
+ case Qt.Key_Return:
+ if (editing) {
+ currentItem.store()
+ changed()
+ editing = false
+ } else {
+ if (currentItem.fields.count > 0)
+ editing = true
+ }
+ break
+ case Qt.Key_Escape:
+ if (editing) {
+ currentItem.reset()
+ editing = false
+ }
+ break
+ case Qt.Key_Delete:
+ editing = false
+ if (currentIndex >= 0 && currentIndex < list.count) {
+ list.remove(currentIndex)
+ changed()
+ }
+ break
+ case Qt.Key_Tab:
+ case Qt.Key_Backtab:
+ // swallow tabs so we don’t lose focus when editing
+ break
+ default:
+ return
+ }
+ event.accepted = true
+ }
+
+ model: ListModel {
+ id: list
+ dynamicRoles: true
+ }
+
+ delegate: Event {
+ id: item
+
+ time: model.time
+ tag: model.tag
+ fields: model.fields
+
+ width: control.width
+ editing: control.editing && ListView.isCurrentItem
+
+ background: Rectangle {
+ anchors.fill: parent
+ color: border.width > 0 ? Util.alphize(border.color, 0.1) :
+ (index % 2 === 0 ? palette.base : palette.alternateBase)
+ border {
+ color: editing ? palette.highlight : palette.dark
+ width: item.ListView.isCurrentItem ? 1 : 0
+ }
+ radius: border.width
+ }
+
+ Connections {
+ enabled: ListView.currentIndex === index
+ function onHeightChanged() {
+ control.positionViewAtIndex(index, ListView.Contain)
+ }
+ }
+ onEditingChanged: {
+ reset()
+ if (editing)
+ forceActiveFocus()
+ }
+ onRemove: {
+ list.remove(ObjectModel.index)
+ }
+ }
+}
diff --git a/Fields/Bool.qml b/Fields/Bool.qml
new file mode 100644
index 0000000..ccb0758
--- /dev/null
+++ b/Fields/Bool.qml
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+
+Row {
+ id: control
+ width: parent.width
+
+ property var definition
+ property alias value: input.checked
+
+ Keys.onPressed: {
+ if (event.text === definition.key) {
+ value = !value
+ event.accepted = true
+ }
+ }
+ function set(val) { value = val || false }
+
+ CheckBox {
+ id: input
+ focusPolicy: Qt.NoFocus
+ padding: 0
+ font.capitalization: Font.SmallCaps
+ }
+}
diff --git a/Fields/Enum.qml b/Fields/Enum.qml
new file mode 100644
index 0000000..30712b6
--- /dev/null
+++ b/Fields/Enum.qml
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+
+import '../util.js' as Util
+
+Column {
+ id: control
+
+ property var definition
+ property int index: -1
+ readonly property string value: index >= 0 ? definition.values.get(index).name : ''
+
+ function set(val) {
+ for (var i = 0; i < definition.values.count; i++) {
+ if (definition.values.get(i).name === val) {
+ index = i
+ return true
+ }
+ }
+ index = -1
+ }
+
+ Keys.onPressed: {
+ for (var i = 0; i < definition.values.count; i++) {
+ if (definition.values.get(i).key === event.text) {
+ index = (index === i ? -1 : i)
+ event.accepted = true
+ break
+ }
+ }
+ }
+
+ Flow {
+ spacing: 5
+ width: parent.width
+
+ ButtonGroup { id: buttons }
+
+ Repeater {
+ model: definition.values
+ delegate: Button {
+ ButtonGroup.group: buttons
+ checkable: true
+ checked: control.index === index
+ focusPolicy: Qt.NoFocus
+
+ implicitWidth: implicitContentWidth + leftPadding + rightPadding
+ padding: 0
+ leftPadding: 5
+ rightPadding: leftPadding
+
+ onClicked: control.index = (control.index === index ? -1 : index)
+ text: Util.addShortcut(name, key)
+ }
+ }
+ }
+}
diff --git a/Fields/Text.qml b/Fields/Text.qml
new file mode 100644
index 0000000..49d7ad2
--- /dev/null
+++ b/Fields/Text.qml
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+
+Label {
+ id: control
+
+ property var definition
+ property alias value: control.text
+
+ Keys.onPressed: {
+ if (event.text === definition.key) {
+ popup.open()
+ event.accepted = true
+ }
+ }
+
+ function set(val) { value = val || '' }
+
+ elide: Text.ElideRight
+
+ Popup {
+ id: popup
+
+ width: control.width
+ height: control.height
+ padding: 0
+
+ onOpened: {
+ input.text = value
+ input.forceActiveFocus()
+ }
+
+ TextInput {
+ id: input
+
+ clip: true
+ padding: 2
+ topPadding: 0
+ bottomPadding: 0
+ width: parent.width
+
+ onAccepted: {
+ value = input.text.trim()
+ popup.close()
+ }
+ }
+ }
+}
diff --git a/Fields/TextArea.qml b/Fields/TextArea.qml
new file mode 100644
index 0000000..20cfeff
--- /dev/null
+++ b/Fields/TextArea.qml
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+
+Label {
+ id: control
+
+ property var definition
+ property alias value: control.text
+
+ Keys.onPressed: {
+ if (event.text === definition.key) {
+ popup.open()
+ event.accepted = true
+ }
+ }
+
+ function set(val) { value = (val || '').trim() }
+
+ wrapMode: Text.Wrap
+
+ Popup {
+ id: popup
+
+ width: control.width
+ height: input.height
+ padding: 0
+
+ onOpened: {
+ input.text = value
+ input.forceActiveFocus()
+ }
+
+ TextArea {
+ id: input
+
+ padding: 2
+ topPadding: 0
+ bottomPadding: 0
+ width: parent.width
+ wrapMode: TextEdit.Wrap
+
+ Keys.onPressed: {
+ if (event.modifiers === Qt.NoModifier) {
+ if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ value = input.text.trim()
+ popup.close()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Filter.qml b/Filter.qml
new file mode 100644
index 0000000..e1b5f93
--- /dev/null
+++ b/Filter.qml
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+import QtQuick.Layouts 1.6
+
+GridLayout {
+ property var tags: []
+
+ function check(item) {
+ if (item.tag === tags.currentText)
+ return true
+ return false
+ }
+
+ signal changed
+
+ columns: 2
+
+ Label { text: qsTr('Tag') }
+ ComboBox {
+ model: tags
+ textRole: 'tag'
+ Layout.fillWidth: true
+ onCurrentTextChanged: changed()
+ }
+
+ Label {
+ text: qsTr('Filters are not implemented yet! 😊')
+ wrapMode: Text.Wrap
+ Layout.fillWidth: true
+ Layout.columnSpan: 2
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1738462
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# Fuzbal
+
+Friendly Usable Zero‐Bullshit Analyzer & Labeler: a keyboard‐driven utility for tagging events in video clips, created for analyzing football matches but likely useful for other kinds of videos.
+
+While functional, this project is not yet production‐ready. While unlikely, it might eat your files or your cake.
+
+## Usage
+
+Open a video. Press `space` to start or stop video playback. Seek with `←` and `→`. Use `,` and `.` to decrease and increase the playback rate, and `=` to reset it.
+
+To add a new event, press the key for the corresponding tag and fill out event details. Custom tags can be defined as a JSON array and loaded at runtime. See `tags.json` for the built‐in example showcasing all supported field types.
+
+Events for `video.mp4` are saved in JSON format in the file `video.mp4.events`. Saved file includes tag definitions, which are loaded automatically when the file is opened. Event timestamps are stored with millisecond precision.
+
+## Compiling
+
+Qt≥5.14 is required. Once Debian catches up, this might be enough:
+
+ # apt install git qtmultimedia-dev qtquickcontrols2-dev qml-module-qtmultimedia qml-module-qtquick-dialogs
+
+One or more of the `gst-plugins` packages are needed at runtime to play videos. Build with:
+
+ $ mkdir build && cd build
+ $ qmake ..
+ $ make
+
+This should create the `fuzbal` binary.
+
+## License
+
+This project is released into the public domain. Breeze icons are distributed under LGPL3+. See `UNLICENSE` and `icons/breeze/LICENSE` for details.
diff --git a/Sidebar.qml b/Sidebar.qml
new file mode 100644
index 0000000..2e61713
--- /dev/null
+++ b/Sidebar.qml
@@ -0,0 +1,245 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+import QtQuick.Layouts 1.6
+import Qt.labs.platform 1.1
+
+Page {
+ id: control
+
+ property bool modified: false
+ property Video video
+
+ function clear() {
+ description.clear()
+ events.clear()
+ }
+
+ function save() {
+ modified = false
+ return {
+ meta: {
+ version: Qt.application.version,
+ video: video.source.toString(),
+ description: description.text
+ },
+ tags: tags.model,
+ events: events.save()
+ }
+ }
+
+ function load(data) {
+ if (data.meta.description !== undefined)
+ description.text = data.meta.description
+ if (data.tags !== undefined)
+ tags.model = data.tags
+ events.load(data.events)
+ modified = false
+ }
+
+ FileDialog {
+ id: videoDialog
+ title: qsTr('Open video')
+ onAccepted: {
+ clear()
+ video.source = currentFile
+ const events = io.read(video.source+'.events')
+ if (events)
+ load(JSON.parse(events))
+ }
+ }
+
+ FileDialog {
+ id: tagsDialog
+ title: qsTr('Load tags')
+ nameFilters: [qsTr('JSON files (*.json)'), qsTr('All files (*)')]
+ onAccepted: tags.model = JSON.parse(io.read(currentFile))
+ }
+
+ Keys.forwardTo: [tags, video]
+
+ header: ToolBar {
+ horizontalPadding: 0
+ RowLayout {
+ anchors.fill: parent
+ ToolButton {
+ action: Action {
+ icon.name: 'document-open'
+ shortcut: StandardKey.Open
+ onTriggered: videoDialog.open()
+ }
+ focusPolicy: Qt.NoFocus
+ }
+ Label {
+ text: video.loaded ? video.source : ''
+ elide: Text.ElideLeft
+ Layout.fillWidth: true
+ }
+ ToolButton {
+ action: Action {
+ onTriggered: io.write(video.source+'.events', JSON.stringify(save()))
+ shortcut: StandardKey.Save
+ icon.name: 'document-save'
+ enabled: video.loaded && control.modified
+ }
+ visible: video.loaded
+ opacity: enabled ? 1 : 0.25
+ focusPolicy: Qt.NoFocus
+ }
+ }
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+
+ // Description box.
+ ColumnLayout {
+ spacing: 0
+ RowLayout {
+ Label {
+ text: qsTr('Description')
+ Layout.fillWidth: true
+ }
+ Label { text: description.enabled ? qsTr('−') : qsTr('+') }
+ TapHandler { onTapped: description.enabled = !description.enabled }
+ }
+
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 100
+ contentWidth: parent.availableWidth
+ padding: 1
+
+ visible: description.enabled
+ background: Frame { }
+ ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+
+ TextArea {
+ id: description
+
+ background: Rectangle { color: palette.base }
+ leftPadding: padding
+ selectByMouse: true
+ wrapMode: Text.Wrap
+
+ onTextChanged: modified = true
+ KeyNavigation.priority: KeyNavigation.BeforeItem
+ KeyNavigation.tab: events
+ }
+ }
+ }
+
+ // Events list.
+ ColumnLayout {
+ spacing: 0
+ Label { text: qsTr('Events') }
+ Frame {
+ padding: 1
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ Events {
+ id: events
+
+ anchors.fill: parent
+ focus: true
+ tags: tags.model
+
+ onEditingChanged: video.pause(editing)
+ onChanged: modified = true
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: !parent.editing
+ onPressed: {
+ const index = events.indexAt(mouse.x, mouse.y)
+ if (index !== -1) {
+ events.currentIndex = index
+ video.seek(events.itemAtIndex(index).time)
+ }
+ forceActiveFocus()
+ }
+ }
+ }
+ }
+ }
+
+ Page {
+ Layout.fillWidth: true
+ Layout.fillHeight: false
+
+ StackLayout {
+ currentIndex: bar.currentIndex
+ implicitHeight: children[currentIndex].implicitHeight
+ width: parent.width
+
+ Frame {
+ padding: 5
+ enabled: visible
+ Layout.fillWidth: true
+
+ ColumnLayout {
+ width: parent.width
+ spacing: 0
+
+ RowLayout {
+ Label {
+ text: qsTr('Tags')
+ Layout.fillWidth: true
+ }
+ ToolButton {
+ icon.name: 'document-open'
+ Layout.alignment: Qt.AlignTop
+ onClicked: tagsDialog.open()
+ focusPolicy:Qt.NoFocus
+ }
+ }
+ Tags {
+ id: tags
+ model: JSON.parse(io.read('qrc:/tags.json'))
+ enabled: video.loaded && !events.editing
+ onClicked: events.create(video.time, tag, fields)
+ Layout.fillWidth: true
+ }
+ }
+ }
+
+ Frame {
+ padding: 5
+ enabled: visible
+ Layout.fillWidth: true
+
+ Filter {
+ id: filter
+ tags: tags.model
+ width: parent.width
+ onChanged: print('filter changed')
+ }
+ }
+ }
+
+ footer: TabBar {
+ id: bar
+ Layout.fillWidth: true
+ ActionGroup { id: tabActions }
+ Repeater {
+ model: [
+ { text: qsTr('&Annotate'), shortcut: qsTr('Alt+A') },
+ { text: qsTr('&Filter'), shortcut: qsTr('Alt+F') }
+ ]
+ delegate: TabButton {
+ action: Action {
+ ActionGroup.group: tabActions
+ shortcut: modelData.shortcut
+ }
+ text: modelData.text
+ focusPolicy: Qt.NoFocus
+ padding: 5
+ onClicked: TabBar.tabBar.setCurrentIndex(index)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tags.qml b/Tags.qml
new file mode 100644
index 0000000..7d3f0ce
--- /dev/null
+++ b/Tags.qml
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+import QtQuick.Layouts 1.6
+
+import 'util.js' as Util
+
+// Tag list.
+Page {
+ id: control
+
+ property alias model: tags.model
+
+ signal clicked(string tag, var fields)
+
+ Keys.enabled: enabled
+ Keys.onPressed: {
+ for (var i = 0; i < model.length; i++) {
+ const tag = model[i]
+ if (tag.key === event.text) {
+ clicked(tag.tag, tag.fields)
+ return
+ }
+ }
+ event.accepted = false
+ }
+
+ RowLayout {
+ width: parent.width
+
+ Flow {
+ spacing: 5
+ Layout.fillWidth: true
+
+ Repeater {
+ id: tags
+ delegate: Button {
+ text: Util.addShortcut(modelData.tag, modelData.key)
+ onClicked: control.clicked(modelData.tag, modelData.fields)
+ focusPolicy: Qt.NoFocus
+ implicitWidth: implicitContentWidth + 2*padding
+ }
+ }
+ }
+ }
+}
diff --git a/UNLICENSE b/UNLICENSE
new file mode 100644
index 0000000..68a49da
--- /dev/null
+++ b/UNLICENSE
@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <http://unlicense.org/>
diff --git a/Video.qml b/Video.qml
new file mode 100644
index 0000000..f63a7ce
--- /dev/null
+++ b/Video.qml
@@ -0,0 +1,150 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.14
+import QtQuick.Controls 2.13
+import QtQuick.Layouts 1.6
+import QtMultimedia 5.11
+
+Page {
+ property bool loaded:
+ media.status !== MediaPlayer.NoMedia &&
+ media.status !== MediaPlayer.InvalidMedia &&
+ media.status !== MediaPlayer.UnknownStatus
+ property alias source: media.source
+ property alias time: media.position
+
+ function pause(yes) {
+ if (yes === undefined)
+ yes = media.playbackState === MediaPlayer.PlayingState
+ if (yes)
+ media.pause()
+ else
+ media.play()
+ }
+
+ function seek(offset, relative) {
+ if (relative)
+ offset += media.position
+ media.seek(offset)
+ }
+
+ Keys.onPressed: {
+ switch (event.key) {
+ // (Un)pause video.
+ case Qt.Key_Space:
+ pause()
+ break
+ // Seek video.
+ case Qt.Key_Left:
+ seek(-500, true)
+ break
+ case Qt.Key_Right:
+ seek(500, true)
+ break
+ // Change playback rate.
+ case Qt.Key_Equal:
+ rate.reset()
+ break
+ case Qt.Key_Comma:
+ rate.decrease()
+ break
+ case Qt.Key_Period:
+ rate.increase()
+ break
+ default:
+ return // don’t accept the event
+ }
+ event.accepted = true
+ }
+
+ // Video.
+ ColumnLayout {
+ spacing: 0
+ anchors.fill: parent
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: 'black'
+ clip: true
+
+ VideoOutput {
+ anchors.fill: parent
+ fillMode: VideoOutput.PreserveAspectFit
+ source: media
+
+ transform: Scale {
+ id: zoom
+ property real scale: 1.0
+ xScale: scale
+ yScale: scale
+ origin.x: wheel.point.position.x
+ origin.y: wheel.point.position.y
+ }
+
+ MediaPlayer {
+ id: media
+ notifyInterval: 100
+ playbackRate: Number.fromLocaleString(rate.displayText)
+ volume: QtMultimedia.convertVolume(
+ volume.value,
+ QtMultimedia.LogarithmicVolumeScale,
+ QtMultimedia.LinearVolumeScale)
+ }
+
+ TapHandler {
+ acceptedButtons: Qt.RightButton
+ onTapped: pause()
+ }
+
+ WheelHandler {
+ id: wheel
+ onWheel: zoom.scale = Math.max(1.0, (event.angleDelta.y > 0 ? 1.1 : 0.9) * zoom.scale)
+ }
+ }
+ }
+
+ // Video controls.
+ RowLayout {
+ Layout.margins: 5
+
+ Button {
+ icon.name: 'media-playback-pause'
+ implicitWidth: implicitHeight
+ checkable: true
+ checked: media.playbackState !== MediaPlayer.PlayingState
+ onClicked: checked ? media.pause() : media.play()
+ }
+ Label { text: new Date(media.position).toISOString().substr(12, 9) }
+ Slider {
+ Layout.fillWidth: true
+ from: 0; to: media.duration
+ value: media.position
+ onMoved: media.seek(value)
+ }
+ Label { text: new Date(media.duration).toISOString().substr(12, 7) }
+
+ Volume {
+ id: volume
+ muted: media.muted
+ focusPolicy: Qt.NoFocus
+ }
+
+ // Playback speed control.
+ SpinBox {
+ id: rate
+ implicitWidth: 80
+ focusPolicy: Qt.NoFocus
+
+ from: 25; to: 250; stepSize: 25
+ value: 100
+
+ function reset() { value = 100 }
+
+ textFromValue: function (value, locale) {
+ return (value / 100).toLocaleString(locale, 'f', 2)
+ }
+ }
+ }
+ }
+}
diff --git a/Volume.qml b/Volume.qml
new file mode 100644
index 0000000..cbf41e9
--- /dev/null
+++ b/Volume.qml
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick.Controls 2.13
+
+Button {
+ property bool muted
+ property alias value: volume.value
+
+ implicitWidth: implicitHeight
+ icon.name: 'audio-volume-' +
+ (muted ? 'muted' :
+ (value < 0.33 ? 'low' :
+ (value < 0.66 ? 'medium' : 'high')))
+
+ checkable: true
+ checked: popup.opened
+
+ onClicked: popup.opened ? popup.close() : popup.open()
+ Popup {
+ id: popup
+ y: -height
+ height: 100
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
+ Slider {
+ id: volume
+ anchors.fill: parent
+ orientation: Qt.Vertical
+ }
+ }
+}
diff --git a/fuzbal.pro b/fuzbal.pro
new file mode 100644
index 0000000..dc20822
--- /dev/null
+++ b/fuzbal.pro
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: Unlicense
+
+QT += multimedia qml quick quickcontrols2 svg widgets
+
+CONFIG += embed_translations lrelease
+
+DEFINES += GIT_VERSION=\\\"$$system(git -C "$$_PRO_FILE_PWD_" describe --always --tags)\\\"
+
+SOURCES += \
+ main.cpp
+
+HEADERS += \
+ io.h
+
+RESOURCES += main.qrc icons.qrc
+
+TRANSLATIONS += translations/fuzbal_sl.ts
diff --git a/icons.qrc b/icons.qrc
new file mode 100644
index 0000000..81773a5
--- /dev/null
+++ b/icons.qrc
@@ -0,0 +1,15 @@
+<!-- SPDX-License-Identifier: Unlicense -->
+<RCC>
+ <qresource prefix="/">
+ <file>icons/breeze/index.theme</file>
+ <file>icons/breeze/actions/symbolic/document-open.svg</file>
+ <file>icons/breeze/actions/symbolic/document-save.svg</file>
+ <file>icons/breeze/actions/symbolic/edit-clear.svg</file>
+ <file>icons/breeze/actions/symbolic/edit-delete.svg</file>
+ <file>icons/breeze/actions/symbolic/media-playback-pause.svg</file>
+ <file>icons/breeze/status/symbolic/audio-volume-high.svg</file>
+ <file>icons/breeze/status/symbolic/audio-volume-low.svg</file>
+ <file>icons/breeze/status/symbolic/audio-volume-medium.svg</file>
+ <file>icons/breeze/status/symbolic/audio-volume-muted.svg</file>
+ </qresource>
+</RCC>
diff --git a/icons/breeze/LICENSE b/icons/breeze/LICENSE
new file mode 100644
index 0000000..65c5ca8
--- /dev/null
+++ b/icons/breeze/LICENSE
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
diff --git a/icons/breeze/actions/symbolic/document-open.svg b/icons/breeze/actions/symbolic/document-open.svg
new file mode 100644
index 0000000..4d2b838
--- /dev/null
+++ b/icons/breeze/actions/symbolic/document-open.svg
@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <defs id="defs3051">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-Text {
+ color:#232629;
+ }
+ </style>
+ </defs>
+ <path style="fill:currentColor;fill-opacity:1;stroke:none"
+ d="M 2 2 L 2 3 L 2 6 L 2 7 L 2 13 L 2 14 L 14 14 L 14 13 L 14 6 L 14 5 L 14 4 L 9.0078125 4 L 7.0078125 2 L 7 2.0078125 L 7 2 L 3 2 L 2 2 z M 3 3 L 6.5917969 3 L 7.59375 4 L 7 4 L 7 4.0078125 L 6.9921875 4 L 4.9921875 6 L 3 6 L 3 3 z M 3 7 L 13 7 L 13 13 L 3 13 L 3 7 z "
+ class="ColorScheme-Text"
+ />
+</svg>
diff --git a/icons/breeze/actions/symbolic/document-save.svg b/icons/breeze/actions/symbolic/document-save.svg
new file mode 100644
index 0000000..cd2db5a
--- /dev/null
+++ b/icons/breeze/actions/symbolic/document-save.svg
@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <defs id="defs3051">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-Text {
+ color:#232629;
+ }
+ </style>
+ </defs>
+ <path style="fill:currentColor;fill-opacity:1;stroke:none"
+ d="M 2 2 L 2 14 L 3 14 L 4 14 L 10 14 L 11 14 L 12 14 L 14 14 L 14 4.28125 L 11.71875 2 L 11.6875 2 L 11 2 L 4 2 L 3 2 L 2 2 z M 3 3 L 4 3 L 5 3 L 5 6 L 5 7 L 11 7 L 11 6 L 11 3 L 11.28125 3 L 13 4.71875 L 13 5 L 13 13 L 12 13 L 12 9 L 11 9 L 5 9 L 3.96875 9 L 3.96875 13 L 3 13 L 3 3 z M 6 3 L 7.90625 3 L 7.90625 6 L 6 6 L 6 3 z M 5 10 L 6 10 L 10 10 L 11 10 L 11 13 L 10 13 L 6 13 L 5 13 L 5 10 z "
+ class="ColorScheme-Text"
+ />
+</svg>
diff --git a/icons/breeze/actions/symbolic/edit-clear.svg b/icons/breeze/actions/symbolic/edit-clear.svg
new file mode 100644
index 0000000..f49be9b
--- /dev/null
+++ b/icons/breeze/actions/symbolic/edit-clear.svg
@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <defs id="defs3051">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-Text {
+ color:#232629;
+ }
+ </style>
+ </defs>
+ <path style="fill:currentColor;fill-opacity:1;stroke:none"
+ d="M 6 2 L 0.70703125 7.2929688 L 0 8 L 0.70703125 8.7070312 L 6 14 L 15 14 L 16 14 L 16 2 L 15 2 L 6 2 z M 8.0019531 5 L 10.011719 7.0097656 L 12.021484 5 L 13.011719 5.9902344 L 11.001953 8 L 13.011719 10.009766 L 12.021484 11 L 10.011719 8.9902344 L 8.0019531 11 L 7.0117188 10.009766 L 9.0214844 8 L 7.0117188 5.9902344 L 8.0019531 5 z "
+ class="ColorScheme-Text"
+ />
+</svg>
diff --git a/icons/breeze/actions/symbolic/edit-delete.svg b/icons/breeze/actions/symbolic/edit-delete.svg
new file mode 100644
index 0000000..9dfb2e0
--- /dev/null
+++ b/icons/breeze/actions/symbolic/edit-delete.svg
@@ -0,0 +1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <defs id="defs3051">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-NegativeText {
+ color:#da4453;
+ }
+ </style>
+ </defs>
+ <path
+ style="fill:currentColor;fill-opacity:1;stroke:none"
+ class="ColorScheme-NegativeText"
+ d="m5 2v2h1v-1h4v1h1v-2h-5zm-3 3v1h2v8h8v-8h2v-1zm3 1h6v7h-6z"
+ />
+</svg>
diff --git a/icons/breeze/actions/symbolic/media-playback-pause.svg b/icons/breeze/actions/symbolic/media-playback-pause.svg
new file mode 100644
index 0000000..37ef343
--- /dev/null
+++ b/icons/breeze/actions/symbolic/media-playback-pause.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-Text {
+ color:#232629;
+ }
+ </style>
+ <path d="m2 2v12h4v-12zm8 0v12h4v-12z" class="ColorScheme-Text" fill="currentColor"/>
+</svg>
diff --git a/icons/breeze/index.theme b/icons/breeze/index.theme
new file mode 100644
index 0000000..cd4128b
--- /dev/null
+++ b/icons/breeze/index.theme
@@ -0,0 +1,580 @@
+[Icon Theme]
+Name=Breeze
+Name[ar]=نسيم
+Name[ast]=Breeze
+Name[ca]=Brisa
+Name[ca@valencia]=Brisa
+Name[cs]=Breeze
+Name[da]=Breeze
+Name[de]=Breeze
+Name[el]=Breeze
+Name[en_GB]=Breeze
+Name[es]=Brisa
+Name[et]=Breeze
+Name[eu]=Breeze
+Name[fi]=Breeze
+Name[fr]=Breeze
+Name[gd]=Oiteag
+Name[gl]=Breeze
+Name[hu]=Breeze
+Name[ia]=Breeze
+Name[id]=Breeze
+Name[it]=Brezza
+Name[ko]=Breeze
+Name[lt]=Breeze
+Name[nl]=Breeze
+Name[nn]=Breeze
+Name[pl]=Bryza
+Name[pt]=Brisa
+Name[pt_BR]=Breeze
+Name[ru]=Breeze
+Name[sk]=Vánok
+Name[sl]=Sapica (Breeze)
+Name[sr]=Поветарац
+Name[sr@ijekavian]=Поветарац
+Name[sr@ijekavianlatin]=Povetarac
+Name[sr@latin]=Povetarac
+Name[sv]=Breeze
+Name[tg]=Насим
+Name[uk]=Breeze
+Name[x-test]=xxBreezexx
+Name[zh_CN]=微风
+Name[zh_TW]=Breeze
+
+Comment=Breeze by the KDE VDG
+Comment[ast]=Breeze pol VDG de KDE
+Comment[ca]=Brisa, creat pel VDG del KDE
+Comment[ca@valencia]=Brisa pel VDG del KDE
+Comment[cs]=Breeze od KDE VDG
+Comment[da]=Breeze af KDE VDG
+Comment[de]=Breeze von der KDE VDG
+Comment[en_GB]=Breeze by the KDE VDG
+Comment[es]=Brisa por KDE VDG
+Comment[et]=Breeze KDE VDG-lt
+Comment[eu]=Breeze, KDE VDGk egina
+Comment[fi]=Breeze KDE VDG:ltä
+Comment[fr]=Breeze, par KDE VDG
+Comment[gl]=Breeze de KDE VDG
+Comment[hu]=Breeze a KDE VDG-től
+Comment[ia]=Breeze (Brisa) per le KDE VDG
+Comment[id]=Breeze oleh KDE VDG
+Comment[it]=Brezza del KDE VDG
+Comment[ko]=KDE 시각 디자인 그룹에서 제작한 Breeze
+Comment[lt]=Breeze pagal KDE VDG
+Comment[nl]=Breeze door de KDE VDG
+Comment[nn]=Breeze frå KDE VDG
+Comment[pl]=Bryza autorstwa KDE VDG
+Comment[pt]=Brisa da VDG do KDE
+Comment[pt_BR]=Breeze pelo KDE VDG
+Comment[ru]=Breeze от KDE VDG
+Comment[sk]=Vánok od KDE VDG
+Comment[sl]=Breeze od KDE VDG
+Comment[sv]=Breeze av KDE:s visuella designgrupp
+Comment[tg]=Насим аз KDE VDG
+Comment[uk]=Breeze, автори — KDE VDG
+Comment[x-test]=xxBreeze by the KDE VDGxx
+Comment[zh_CN]=微风,由 KDE VDG 创作
+Comment[zh_TW]=由 KDE VDG 團隊製作的 Breeze
+
+DisplayDepth=32
+
+Inherits=hicolor
+
+Example=folder
+
+FollowsColorScheme=true
+
+DesktopDefault=48
+DesktopSizes=16,22,32,48,64,128,256
+ToolbarDefault=22
+ToolbarSizes=16,22,32,48
+MainToolbarDefault=22
+MainToolbarSizes=16,22,32,48
+SmallDefault=16
+SmallSizes=16,22,32,48
+PanelDefault=48
+PanelSizes=16,22,32,48,64,128,256
+DialogDefault=32
+DialogSizes=16,22,32,48,64,128,256
+
+KDE-Extensions=.svg
+
+########## Directories
+########## ordered by category and alphabetically
+
+Directories=actions/12,actions/16,actions/22,actions/24,actions/32,actions/64,animations/16,animations/22,apps/16,apps/22,apps/32,apps/48,preferences/32,applets/22,applets/48,applets/64,applets/128,applets/256,categories/32,devices/16,devices/22,devices/64,emblems/8,emblems/16,emblems/22,emotes/22,mimetypes/16,mimetypes/22,mimetypes/32,mimetypes/64,places/16,places/22,places/32,places/64,status/16,status/22,status/24,status/32,status/64,actions/symbolic,devices/symbolic,emblems/symbolic,places/symbolic,status/symbolic
+ScaledDirectories=actions/16@2x,actions/22@2x,actions/24@2x,actions/32@2x,animations/16@2x,apps/16@2x,apps/22@2x,devices/16@2x,devices/22@2x,emblems/16@2x,emblems/22@2x,emotes/22@2x,mimetypes/16@2x,mimetypes/22@2x,places/16@2x,places/22@2x,status/16@2x,status/22@2x
+
+########## Actions
+########## ordered by size
+
+#12x12 - Fixed size - For Inkscape
+[actions/12]
+Size=12
+Context=Actions
+Type=Fixed
+
+#16x16 - Fixed size - For use in sidebar(s) smaller toolbar(s) >!!!ONLY!!!<: e.g. Kate movable sidebar/toolbar (search and replace, current project, etc.) or Juk tree view - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/16]
+Size=16
+Context=Actions
+Type=Fixed
+
+#16x16@2x - Fixed size - For use in sidebar(s) smaller toolbar(s) >!!!ONLY!!!<: e.g. Kate movable sidebar/toolbar (search and replace, current project, etc.) or Juk tree view - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/16@2x]
+Size=16
+Scale=2
+Context=Actions
+Type=Fixed
+
+#22x22 - Fixed size - For toolbar icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/22]
+Size=22
+Context=Actions
+Type=Fixed
+
+#22x22@2x - Fixed size - For toolbar icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/22@2x]
+Size=22
+Scale=2
+Context=Actions
+Type=Fixed
+
+#24x24 - Fixed size - GTK icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/24]
+Size=24
+Context=Actions
+Type=Fixed
+
+#24x24@2x - Fixed size - GTK icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/24@2x]
+Size=24
+Scale=2
+Context=Actions
+Type=Fixed
+
+#32x32 - Fixed size - For toolbar icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/32]
+Size=32
+Context=Actions
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+#32x32@2x - Fixed size - For toolbar icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/32@2x]
+Size=32
+Scale=2
+Context=Actions
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+#64x64 - Fixed size - For toolbar icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[actions/64]
+Size=64
+Context=Actions
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+########## Animations
+########## ordered by size
+
+#16x16 - Fixed size - Application icon(s) for Dolphin sidebar - OPTIONAL + DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[animations/16]
+Size=16
+Context=Animations
+Type=Fixed
+
+#16x16@2x - Fixed size - Application icon(s) for Dolphin sidebar - OPTIONAL + DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[animations/16@2x]
+Size=16
+Scale=2
+Context=Animations
+Type=Fixed
+
+#22x22 - Scalable
+[animations/22]
+Size=22
+Context=Animations
+Type=Scalable
+MinSize=22
+MaxSize=256
+
+########## Apps
+########## ordered by size
+
+#16x16 - Fixed size - Application icon(s) for Dolphin sidebar - OPTIONAL + DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[apps/16]
+Size=16
+Context=Applications
+Type=Fixed
+
+#16x16@2x - Fixed size - Application icon(s) for Dolphin sidebar - OPTIONAL + DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[apps/16@2x]
+Size=16
+Scale=2
+Context=Applications
+Type=Fixed
+
+#22x22 - Fixed size - Workaround icon(s) for toolbar(s) button(s) e.g. Dolphin Open Terminal/About Dolphin/About KDE buttons - WRONG_ICON_USAGE_BY_APP - Monochrome
+[apps/22]
+Size=22
+Context=Applications
+Type=Fixed
+
+#22x22@2x - Fixed size - Workaround icon(s) for toolbar(s) button(s) e.g. Dolphin Open Terminal/About Dolphin/About KDE buttons - WRONG_ICON_USAGE_BY_APP - Monochrome
+[apps/22@2x]
+Size=22
+Scale=2
+Context=Applications
+Type=Fixed
+
+#32x32 - Fixed size - For System Settings icons >!!!ONLY!!!< - Scalable to the following sizes: 32x32 (default), 64x64, 128x128, 256x256 - DO_NOT_USE_ANYWHERE_ELSE - Color
+[apps/32]
+Size=32
+Context=Applications
+Type=Fixed
+
+#48x48 - Scalable - For application icons >!!!ONLY!!!< - Scalable to the following sizes: 48x48 (default), 96x96 and 24x24 (not recommended) - DO_NOT_USE_ANYWHERE_ELSE - Color
+[apps/48]
+Size=48
+Context=Applications
+Type=Scalable
+MinSize=48
+MaxSize=256
+
+#32x32 - Fixed size - For System Settings icons >!!!ONLY!!!< - Scalable to the following sizes: 32x32 (default), 64x64, 128x128, 256x256 - DO_NOT_USE_ANYWHERE_ELSE - Color
+[preferences/32]
+Size=32
+Context=Applications
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+#256x256 - Color for applets
+[applets/22]
+Size=22
+Context=Status
+Type=Scalable
+MinSize=22
+MaxSize=256
+
+#256x256 - Color for applets
+[applets/48]
+Size=48
+Context=Status
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+#256x256 - Animation icons for kwin desktop effects
+[applets/64]
+Size=64
+Context=Status
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+#256x256 - Color
+[applets/128]
+Size=128
+Context=Applications
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+#256x256 - Scalable - For applets / widgets icons >!!!ONLY!!! - DO_NOT_USE_ANYWHERE_ELSE - Color
+[applets/256]
+Size=256
+Context=Applications
+Type=Scalable
+MinSize=48
+MaxSize=256
+
+########## Categories
+########## ordered by size
+
+#32x32 - Fixed size - For categories icons >!!!ONLY!!!< - Used in Kickoff (KDE 4.x.x) and Lancelot. Also used in MATE and Cinnamon (just FYI) - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[categories/32]
+Size=32
+Context=Categories
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+########## Devices
+########## ordered by size
+
+#16x16 - Fixed size - For small device icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[devices/16]
+Size=16
+Context=Devices
+Type=Fixed
+
+
+#16x16@2x - Fixed size - For small device icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[devices/16@2x]
+Size=16
+Scale=2
+Context=Devices
+Type=Fixed
+
+#22x22 - Fixed size - For small device icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[devices/22]
+Size=22
+Context=Devices
+Type=Fixed
+
+#22x22@2x - Fixed size - For small device icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[devices/22@2x]
+Size=22
+Scale=2
+Context=Devices
+Type=Fixed
+
+#64x64 - Scalable - For device icons >!!!ONLY!!!< - Scalable to the following sizes: 64x64 (default), 32x32, 128x128, 256x256 - DO_NOT_USE_ANYWHERE_ELSE - Color
+[devices/64]
+Size=64
+Context=Devices
+Type=Scalable
+MinSize=24
+MaxSize=256
+
+########## Emblems
+########## ordered by size
+
+#8x8 - Fixed size - File system emblems - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[emblems/8]
+Size=8
+Context=Emblems
+Type=Fixed
+
+#16x16 - Fixed size - File system emblems - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[emblems/16]
+Size=16
+Context=Emblems
+Type=Fixed
+
+#16x16@2x - Fixed size - File system emblems - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[emblems/16@2x]
+Size=16
+Scale=2
+Context=Emblems
+Type=Fixed
+
+#22x22 - Fixed size - File system emblems - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[emblems/22]
+Size=22
+Context=Emblems
+Type=Fixed
+
+#22x22@2x - Fixed size - File system emblems - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[emblems/22@2x]
+Size=22
+Scale=2
+Context=Emblems
+Type=Fixed
+
+########## Emoticons
+########## ordered by size
+
+#22x22 - Fixed size - Emoticons - DO_NOT_USE_ANYWHERE_ELSE - Color/flat
+[emotes/22]
+Size=22
+Context=Emotes
+Type=Fixed
+
+#22x22@2x - Fixed size - Emoticons - DO_NOT_USE_ANYWHERE_ELSE - Color/flat
+[emotes/22@2x]
+Size=22
+Scale=2
+Context=Emotes
+Type=Fixed
+
+########## Mimetypes
+########## ordered by size
+
+#16x16 - Fixed size - For small file type icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[mimetypes/16]
+Size=16
+Context=MimeTypes
+Type=Fixed
+MinSize=16
+
+#16x16@2x - Fixed size - For small file type icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[mimetypes/16@2x]
+Size=16
+Scale=2
+Context=MimeTypes
+Type=Fixed
+MinSize=16
+
+#22x22 - Fixed size - For small file type icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[mimetypes/22]
+Size=22
+Context=MimeTypes
+Type=Scalable
+MinSize=22
+MaxSize=24
+
+#22x22@2x - Fixed size - For small file type icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[mimetypes/22@2x]
+Size=22
+Scale=2
+Context=MimeTypes
+Type=Scalable
+MinSize=22
+MaxSize=24
+
+#32x32 - Scalable - For file type icons >!!!ONLY!!!< - Scalable to the following sizes: 64x64 (default), 32x32, 128x128, 256x256 - DO_NOT_USE_ANYWHERE_ELSE - Color
+[mimetypes/32]
+Size=32
+Context=MimeTypes
+Type=Scalable
+MinSize=32
+MaxSize=48
+
+#64x64 - Scalable - For file type icons >!!!ONLY!!!< - Scalable to the following sizes: 64x64 (default), 32x32, 128x128, 256x256 - DO_NOT_USE_ANYWHERE_ELSE - Color
+[mimetypes/64]
+Size=64
+Context=MimeTypes
+Type=Scalable
+MinSize=64
+MaxSize=256
+
+########## Places
+########## ordered by size
+
+#16x16 - Fixed size - For small folder icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[places/16]
+Size=16
+Context=Places
+Type=Fixed
+MinSize=16
+
+#16x16@2x - Fixed size - For small folder icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[places/16@2x]
+Size=16
+Scale=2
+Context=Places
+Type=Fixed
+MinSize=16
+
+#22x22 - Fixed size - Workaround icon(s) for toolbar(s) button(s) e.g. KMail trash icon - WRONG_ICON_USAGE_BY_APP - Monochrome
+[places/22]
+Size=22
+Context=Places
+Type=Fixed
+
+#22x22@2x - Fixed size - Workaround icon(s) for toolbar(s) button(s) e.g. KMail trash icon - WRONG_ICON_USAGE_BY_APP - Monochrome
+[places/22@2x]
+Size=22
+Scale=2
+Context=Places
+Type=Fixed
+
+#32x32 - Scalable - For folder icons >!!!ONLY!!!< - Scalable to the following sizes: 64x64 (default), 32x32, 128x128, 256x256 - DO_NOT_USE_ANYWHERE_ELSE - Color
+[places/32]
+Size=32
+Context=Places
+Type=Scalable
+MinSize=24
+MaxSize=48
+
+#64x64 - Scalable - For folder icons >!!!ONLY!!!< - Scalable to the following sizes: 64x64 (default), 32x32, 128x128, 256x256 - DO_NOT_USE_ANYWHERE_ELSE - Color
+[places/64]
+Size=64
+Context=Places
+Type=Scalable
+MinSize=32
+MaxSize=256
+
+########## Status
+########## ordered by size
+
+#16x16 - Fixed size - For IM status icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[status/16]
+Size=16
+Context=Status
+Type=Fixed
+
+#16x16@2x - Fixed size - For IM status icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[status/16@2x]
+Size=16
+Scale=2
+Context=Status
+Type=Fixed
+
+#22x22 - Fixed size - Icon(s) for Plasma theme/System Tray. Not particularly used on Plasma. - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[status/22]
+Size=22
+Context=Status
+Type=Fixed
+
+#22x22@2x - Fixed size - Icon(s) for Plasma theme/System Tray. Not particularly used on Plasma. - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[status/22@2x]
+Size=22
+Scale=2
+Context=Status
+Type=Fixed
+
+#24x24 - Fixed size - for GTK apps. - WRONG_ICON_USAGE_BY_APP - Monochrome
+[status/24]
+Size=24
+Context=Status
+Type=Fixed
+
+#32x32 - Fixed size - Icon(s) for Plasma theme/System Tray. Not particularly used on Plasma. - DO_NOT_USE_ANYWHERE_ELSE - Monochrome
+[status/32]
+Size=32
+Context=Status
+Type=Fixed
+
+#64x64 - Fixed size - For dialog icons >!!!ONLY!!!< - DO_NOT_USE_ANYWHERE_ELSE - Color
+[status/64]
+Size=64
+Context=Status
+Type=Scalable
+MinSize=22
+MaxSize=256
+
+# Gnome symbolic icons
+
+[actions/symbolic]
+Context=Actions
+Size=16
+MinSize=8
+MaxSize=512
+Type=Scalable
+
+[devices/symbolic]
+Context=Devices
+Size=16
+MinSize=8
+MaxSize=512
+Type=Scalable
+
+[emblems/symbolic]
+Context=Emblems
+Size=16
+MinSize=8
+MaxSize=512
+Type=Scalable
+
+[places/symbolic]
+Context=Places
+Size=16
+MinSize=8
+MaxSize=512
+Type=Scalable
+
+[status/symbolic]
+Context=Status
+Size=16
+MinSize=8
+MaxSize=512
+Type=Scalable
+
+########## EOF
diff --git a/icons/breeze/status/symbolic/audio-volume-high.svg b/icons/breeze/status/symbolic/audio-volume-high.svg
new file mode 100644
index 0000000..3ec9ff2
--- /dev/null
+++ b/icons/breeze/status/symbolic/audio-volume-high.svg
@@ -0,0 +1,13 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <defs id="defs3051">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-Text {
+ color:#232629;
+ }
+ </style>
+ </defs>
+ <path
+ style="opacity:1;fill:currentColor;fill-opacity:1;stroke:none"
+ d="M 8 2 L 4 6 L 4 10 L 8 14 L 9 14 L 9 2 L 8 2 z M 12.064453 3 L 11.306641 3.4804688 C 12.423224 4.8284501 13.01704 6.3963674 13.017578 7.9980469 C 13.013278 9.5983362 12.416776 11.164113 11.298828 12.509766 L 12.068359 13 C 13.32703 11.513919 13.998369 9.7754967 14 7.9980469 C 13.996227 6.2216198 13.323562 4.4846369 12.064453 3 z M 10.767578 4.4609375 L 10 4.9472656 C 10.698954 5.8743941 11.06889 6.9282736 11.072266 8.0019531 C 11.069966 9.0761572 10.700653 10.130745 10.001953 11.058594 L 10.769531 11.544922 C 11.607241 10.476929 12.05168 9.2516769 12.054688 8.0019531 C 12.050587 6.7527495 11.60553 5.5282095 10.767578 4.4609375 z M 2 6 L 2 10 L 3 10 L 3 6 L 2 6 z "
+ class="ColorScheme-Text"/>
+</svg>
diff --git a/icons/breeze/status/symbolic/audio-volume-low.svg b/icons/breeze/status/symbolic/audio-volume-low.svg
new file mode 100644
index 0000000..57cf679
--- /dev/null
+++ b/icons/breeze/status/symbolic/audio-volume-low.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <defs id="defs3051">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-Text {
+ color:#232629;
+ }
+ </style>
+ </defs>
+ <path
+ style="fill:currentColor;fill-opacity:1;stroke:none"
+ d="M 8,2 4,6 4,10 8,14 9,14 9,2 8,2 Z M 2,6 2,10 3,10 3,6 2,6 Z"
+ class="ColorScheme-Text"/>
+ <path
+ style="fill:currentColor;fill-opacity:0.25;stroke:none"
+ d="M 12.064453 3 L 11.306641 3.4804688 C 12.423224 4.8284502 13.01704 6.3963674 13.017578 7.9980469 C 13.013278 9.5983362 12.416776 11.164113 11.298828 12.509766 L 12.068359 13 C 13.32703 11.513919 13.998369 9.7754967 14 7.9980469 C 13.996227 6.2216198 13.323562 4.4846369 12.064453 3 z M 10.767578 4.4609375 L 10 4.9472656 C 10.698954 5.8743941 11.06889 6.9282736 11.072266 8.0019531 C 11.069966 9.0761572 10.700653 10.130745 10.001953 11.058594 L 10.769531 11.544922 C 11.607241 10.476929 12.051681 9.2516769 12.054688 8.0019531 C 12.050588 6.7527495 11.60553 5.5282095 10.767578 4.4609375 z "
+ class="ColorScheme-Text"/>
+</svg>
diff --git a/icons/breeze/status/symbolic/audio-volume-medium.svg b/icons/breeze/status/symbolic/audio-volume-medium.svg
new file mode 100644
index 0000000..17e0295
--- /dev/null
+++ b/icons/breeze/status/symbolic/audio-volume-medium.svg
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+ <defs id="defs3051">
+ <style type="text/css" id="current-color-scheme">
+ .ColorScheme-Text {
+ color:#232629;
+ }
+ </style>
+ </defs>
+ <path
+ style="fill:currentColor;fill-opacity:1;stroke:none"
+ d="M 8 2 L 4 6 L 4 10 L 8 14 L 9 14 L 9 2 L 8 2 z M 10.767578 4.4609375 L 10 4.9472656 C 10.698954 5.8743941 11.06889 6.9282736 11.072266 8.0019531 C 11.069966 9.0761572 10.700653 10.130745 10.001953 11.058594 L 10.769531 11.544922 C 11.607241 10.476929 12.051681 9.2516769 12.054688 8.0019531 C 12.050588 6.7527495 11.60553 5.5282095 10.767578 4.4609375 z M 2 6 L 2 10 L 3 10 L 3 6 L 2 6 z "
+ class="ColorScheme-Text"/>
+ <path
+ style="fill:currentColor;fill-opacity:0.25;stroke:none"
+ d="m 12.064625,3 -0.757812,0.4804688 c 1.116583,1.3479814 1.710399,2.9158986 1.710937,4.5175781 -0.0043,1.6002893 -0.600802,3.1660661 -1.71875,4.5117191 L 12.068531,13 C 13.327202,11.513919 13.998541,9.7754967 14.000172,7.9980469 13.996399,6.2216198 13.323734,4.4846369 12.064625,3 Z"
+ class="ColorScheme-Text"/>
+</svg>
diff --git a/icons/breeze/status/symbolic/audio-volume-muted.svg b/icons/breeze/status/symbolic/audio-volume-muted.svg
new file mode 100644
index 0000000..f74a4a9
--- /dev/null
+++ b/icons/breeze/status/symbolic/audio-volume-muted.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+ <style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#232629;}.ColorScheme-NegativeText{color:#da4453;}</style>
+ <path class="ColorScheme-Text" d="m8 2-4 4v4l4 4h1v-12zm-6 4v4h1v-4z" fill="currentColor"/>
+ <path class="ColorScheme-NegativeText" d="m15.98633 8.0000197a3.0000002 3.0000002 0 0 1-0.55664 1.736328l0.0019 0.0019-0.01172 0.01176a3.0000002 3.0000002 0 0 1-0.677727 0.6777123l-0.01758 0.01758-0.0039-0.0038a3.0000002 3.0000002 0 0 1-1.734376 0.55852 3.0000002 3.0000002 0 0 1-2.9999993-3.0000003 3.0000002 3.0000002 0 0 1 0.5566403-1.736328l-0.0019-0.0019 0.01164-0.01164a3.0000002 3.0000002 0 0 1 0.677758-0.677914l0.01758-0.01758 0.0039 0.0038a3.0000002 3.0000002 0 0 1 1.734424-0.558478 3.0000002 3.0000002 0 0 1 3 3zm-1 0a2.0000002 2.0000002 0 0 0-2-1.999999 2.0000002 2.0000002 0 0 0-1.013672 0.279295l2.734376 2.734375a2.0000002 2.0000002 0 0 0 0.279296-1.013671zm-0.986328 1.720703-2.734375-2.734374a2.0000002 2.0000002 0 0 0-0.279297 1.013671 2.0000002 2.0000002 0 0 0 2 1.9999993 2.0000002 2.0000002 0 0 0 1.013672-0.2792963z" fill="currentColor"/>
+</svg>
diff --git a/io.h b/io.h
new file mode 100644
index 0000000..0d9b238
--- /dev/null
+++ b/io.h
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: Unlicense
+
+#ifndef IO_H
+#define IO_H
+
+#include <QFile>
+#include <QObject>
+#include <QString>
+#include <QUrl>
+#include <QtDebug>
+
+class IO : public QObject {
+ Q_OBJECT
+public slots:
+ void write(const QUrl &url, const QString &data) {
+ QFile file{urlToPath(url)};
+ if (file.open(QIODevice::WriteOnly | QIODevice::Text))
+ file.write(data.toUtf8());
+ else
+ qWarning() << "error opening file" << url;
+ }
+
+ QString read(const QUrl &url) {
+ QFile file{urlToPath(url)};
+ if (file.open(QIODevice::ReadOnly))
+ return file.readAll();
+ qWarning() << "error opening file" << url;
+ return {};
+ }
+
+private:
+ static const QString urlToPath(const QUrl &path) {
+ return path.scheme() == "qrc" ? (":" + path.path()) : path.toLocalFile();
+ }
+};
+
+#endif
diff --git a/main.cpp b/main.cpp
new file mode 100644
index 0000000..a388f27
--- /dev/null
+++ b/main.cpp
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: Unlicense
+
+#include <exception>
+
+#include <QApplication>
+#include <QCommandLineParser>
+#include <QIcon>
+#include <QObject>
+#include <QQmlApplicationEngine>
+#include <QQmlContext>
+#include <QQmlEngine>
+#include <QTextCodec>
+#include <QTextStream>
+#include <QTranslator>
+#include <QtDebug>
+
+#include <io.h>
+
+int main(int argc, char *argv[])
+try {
+ if (QIcon::themeName().isEmpty()) {
+ QIcon::setThemeSearchPaths(QIcon::themeSearchPaths() << ":/icons");;
+ QIcon::setThemeName("breeze");
+ }
+
+ QApplication app{argc, argv};
+ app.setOrganizationName("fuzbal");
+ app.setApplicationName("fuzbal");
+ app.setApplicationVersion(GIT_VERSION);
+
+ QTranslator translator;
+ translator.load(QLocale(), "fuzbal", "_", ":/i18n");
+ app.installTranslator(&translator);
+
+ QCommandLineParser parser;
+ parser.setApplicationDescription("Friendly Usable Zero-Bullshit Annotator & Labeler");
+ parser.addHelpOption();
+ parser.addVersionOption();
+ parser.process(app);
+
+ IO io;
+
+ QQmlApplicationEngine engine;
+ engine.rootContext()->setContextProperty("io", &io);
+ engine.load(QUrl{"qrc:/main.qml"});
+
+ return app.exec();
+} catch (std::exception &e) {
+ qCritical() << "critical error:" << e.what();
+ return 1;
+} catch (...) {
+ qCritical() << "critical error";
+ return 1;
+}
diff --git a/main.qml b/main.qml
new file mode 100644
index 0000000..7e4d720
--- /dev/null
+++ b/main.qml
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: Unlicense
+
+import QtQuick 2.12
+import QtQuick.Controls 2.13
+import QtQuick.Layouts 1.6
+
+ApplicationWindow {
+ visible: true
+ width: 1024
+ height: 768
+
+ SplitView {
+ anchors.fill: parent
+ hoverEnabled: true
+
+ handle: Rectangle {
+ implicitWidth: sidebar.rightPadding
+ implicitHeight: implicitWidth
+ color: SplitHandle.pressed ? palette.dark :
+ (SplitHandle.hovered ? palette.mid : 'transparent')
+ }
+
+ Video {
+ id: video
+ SplitView.fillWidth: true
+ SplitView.minimumWidth: parent.width/2
+ }
+
+ Sidebar {
+ id: sidebar
+ video: video
+ padding: 5
+ leftPadding: 0
+ focus: true
+ SplitView.fillHeight: true
+ SplitView.preferredWidth: 300
+ SplitView.minimumWidth: 200
+ }
+ }
+}
diff --git a/main.qrc b/main.qrc
new file mode 100644
index 0000000..8a4a58d
--- /dev/null
+++ b/main.qrc
@@ -0,0 +1,20 @@
+<!-- SPDX-License-Identifier: Unlicense -->
+<RCC>
+ <qresource prefix="/">
+ <file>main.qml</file>
+ <file>qtquickcontrols2.conf</file>
+ <file>tags.json</file>
+ <file>util.js</file>
+ <file>Event.qml</file>
+ <file>Events.qml</file>
+ <file>Fields/Bool.qml</file>
+ <file>Fields/Enum.qml</file>
+ <file>Fields/Text.qml</file>
+ <file>Fields/TextArea.qml</file>
+ <file>Filter.qml</file>
+ <file>Tags.qml</file>
+ <file>Sidebar.qml</file>
+ <file>Video.qml</file>
+ <file>Volume.qml</file>
+ </qresource>
+</RCC>
diff --git a/qtquickcontrols2.conf b/qtquickcontrols2.conf
new file mode 100644
index 0000000..9cd1411
--- /dev/null
+++ b/qtquickcontrols2.conf
@@ -0,0 +1,2 @@
+[Controls]
+Style=Fusion
diff --git a/tags.json b/tags.json
new file mode 100644
index 0000000..978e44f
--- /dev/null
+++ b/tags.json
@@ -0,0 +1,56 @@
+[
+ {
+ "tag": "pass", "key": "p", "fields": [
+ { "name": "player", "type": "Text", "key": "p" },
+ {
+ "name": "type", "type": "Enum",
+ "values": [
+ { "name": "long", "key": "l" },
+ { "name": "short", "key": "s" }
+ ]
+ },
+ { "name": "success", "type": "Bool", "key": "u", "value": true },
+ { "name": "comment", "type": "TextArea", "key": "c" }
+ ]
+ },
+ {
+ "tag": "shot", "key": "s", "fields": [
+ { "name": "player", "type": "Text", "key": "p" },
+ {
+ "name": "outcome", "type": "Enum",
+ "values": [
+ { "name": "goal", "key": "g" },
+ { "name": "block", "key": "b" },
+ { "name": "deflect", "key": "d" },
+ { "name": "out", "key": "o" }
+ ]
+ },
+ { "name": "comment", "type": "TextArea", "key": "c" }
+ ]
+ },
+ {
+ "tag": "foul", "key": "f", "fields": [
+ { "name": "player", "type": "Text", "key": "p" },
+ { "name": "opponent", "type": "Text", "key": "o" },
+ { "name": "comment", "type": "TextArea", "key": "c" }
+ ]
+ },
+ {
+ "tag": "offside", "key": "o", "fields": [
+ { "name": "player", "type": "Text", "key": "p" },
+ { "name": "comment", "type": "TextArea", "key": "c" }
+ ]
+ },
+ {
+ "tag": "assist", "key": "a", "fields": [
+ { "name": "player", "type": "Text", "key": "p" },
+ { "name": "comment", "type": "TextArea", "key": "c" }
+ ]
+ },
+ {
+ "tag": "offense", "key": "O", "fields": []
+ },
+ {
+ "tag": "defense", "key": "D", "fields": []
+ }
+]
diff --git a/translations/fuzbal_sl.ts b/translations/fuzbal_sl.ts
new file mode 100644
index 0000000..ba5ea39
--- /dev/null
+++ b/translations/fuzbal_sl.ts
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="sl_SI">
+<context>
+ <name>Filter</name>
+ <message>
+ <location filename="../Filter.qml" line="20"/>
+ <source>Tag</source>
+ <translation>Oznaka</translation>
+ </message>
+ <message>
+ <location filename="../Filter.qml" line="29"/>
+ <source>Filters are not implemented yet! 😊</source>
+ <translation>Filtriranje še ne deluje! 😊</translation>
+ </message>
+</context>
+<context>
+ <name>Sidebar</name>
+ <message>
+ <location filename="../Sidebar.qml" line="43"/>
+ <source>Open video</source>
+ <translation>Odpri video</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="55"/>
+ <source>Load tags</source>
+ <translation>Naloži oznake</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="56"/>
+ <source>JSON files (*.json)</source>
+ <translation>Datoteke JSON (*.json)</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="56"/>
+ <source>All files (*)</source>
+ <translation>Vse datoteke (*)</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="101"/>
+ <source>Description</source>
+ <translation>Opis</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="104"/>
+ <source>−</source>
+ <translation></translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="104"/>
+ <source>+</source>
+ <translation></translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="136"/>
+ <source>Events</source>
+ <translation>Dogodki</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="188"/>
+ <source>Tags</source>
+ <translation>Oznake</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="228"/>
+ <source>&amp;Annotate</source>
+ <translation>&amp;Označi</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="228"/>
+ <source>Alt+A</source>
+ <translation>Alt+O</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="229"/>
+ <source>&amp;Filter</source>
+ <translation>&amp;Filtriraj</translation>
+ </message>
+ <message>
+ <location filename="../Sidebar.qml" line="229"/>
+ <source>Alt+F</source>
+ <translation></translation>
+ </message>
+</context>
+</TS>
diff --git a/util.js b/util.js
new file mode 100644
index 0000000..61f70e4
--- /dev/null
+++ b/util.js
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: Unlicense
+
+// If text contains key, make it stand out; otherwise, append [key] to text.
+function addShortcut(text, key) {
+ if (!key)
+ return text
+ else if (text.indexOf(key) < 0)
+ return `${text} [<b>${key}</b>]`
+ else
+ return text.replace(new RegExp('\(' + key + '\)'), '<b>$1</b>')
+}
+
+// Set alpha value for color.
+function alphize(color, alpha) {
+ return Qt.hsla(color.hslHue, color.hslSaturation, color.hslLightness, alpha)
+}
+
+// Return the last event in list with property not greater than value.
+function find(list, property, value) {
+ var low = 0
+ var high = list.count - 1
+ while (low <= high) {
+ var mid = Math.floor((low + high) / 2)
+ if (list.get(mid)[property] <= value)
+ low = mid + 1
+ else
+ high = mid - 1
+ }
+ return low
+}