First pass

This commit is contained in:
ItsLemmy
2025-09-22 19:11:10 -04:00
parent 9a7fb4a219
commit b8f4401878
50 changed files with 35 additions and 34 deletions
+373
View File
@@ -0,0 +1,373 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Modules.Audio
import qs.Commons
import qs.Services
import qs.Widgets
NBox {
id: root
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
// No media player detected
ColumnLayout {
id: fallback
visible: !main.visible
spacing: Style.marginS * scaling
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
NIcon {
icon: "disc"
font.pointSize: Style.fontSizeXXXL * 3 * scaling
color: Color.mPrimary
Layout.alignment: Qt.AlignHCenter
}
NText {
text: "No media player detected"
color: Color.mOnSurfaceVariant
Layout.alignment: Qt.AlignHCenter
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}
}
// MediaPlayer Main Content
ColumnLayout {
id: main
visible: MediaService.currentPlayer && MediaService.canPlay
spacing: Style.marginM * scaling
// Player selector
ComboBox {
id: playerSelector
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * 0.83 * scaling
visible: MediaService.getAvailablePlayers().length > 1
model: MediaService.getAvailablePlayers()
textRole: "identity"
currentIndex: MediaService.selectedPlayerIndex
background: Rectangle {
visible: false
// implicitWidth: 120 * scaling
// implicitHeight: 30 * scaling
color: Color.transparent
border.color: playerSelector.activeFocus ? Color.mSecondary : Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
radius: Style.radiusM * scaling
}
contentItem: NText {
visible: false
leftPadding: Style.marginM * scaling
rightPadding: playerSelector.indicator.width + playerSelector.spacing
text: playerSelector.displayText
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurface
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
indicator: NIcon {
x: playerSelector.width - width
y: playerSelector.topPadding + (playerSelector.availableHeight - height) / 2
icon: "caret-down"
font.pointSize: Style.fontSizeXXL * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignRight
}
popup: Popup {
id: popup
x: playerSelector.width * 0.5
y: playerSelector.height * 0.75
width: playerSelector.width * 0.5
implicitHeight: Math.min(160 * scaling, contentItem.implicitHeight + Style.marginM * scaling)
padding: Style.marginS * scaling
onOpened: {
PanelService.willOpenPopup(root)
}
onClosed: {
PanelService.willClosePopup(root)
}
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: playerSelector.popup.visible ? playerSelector.delegateModel : null
currentIndex: playerSelector.highlightedIndex
ScrollIndicator.vertical: ScrollIndicator {}
}
background: Rectangle {
color: Color.mSurface
border.color: Color.mOutline
border.width: Math.max(1, Style.borderS * scaling)
radius: Style.radiusXS * scaling
}
}
delegate: ItemDelegate {
width: playerSelector.width
contentItem: NText {
text: modelData.identity
font.pointSize: Style.fontSizeS * scaling
color: highlighted ? Color.mSurface : Color.mOnSurface
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
highlighted: playerSelector.highlightedIndex === index
background: Rectangle {
width: popup.width - Style.marginS * scaling * 2
color: highlighted ? Color.mSecondary : Color.transparent
radius: Style.radiusXS * scaling
}
}
onActivated: {
MediaService.selectedPlayerIndex = currentIndex
MediaService.updateCurrentPlayer()
}
}
RowLayout {
spacing: Style.marginM * scaling
// -------------------------
// Rounded thumbnail image
Rectangle {
width: 90 * scaling
height: 90 * scaling
radius: width * 0.5
color: trackArt.visible ? Color.mPrimary : Color.transparent
clip: true
// Can't use fallback icon here, as we have a big disc behind
NImageCircled {
id: trackArt
visible: MediaService.trackArtUrl !== ""
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
imagePath: MediaService.trackArtUrl
borderColor: Color.mOutline
borderWidth: Math.max(1, Style.borderS * scaling)
}
// Fallback icon when no album art available
NIcon {
icon: "disc"
color: Color.mPrimary
font.pointSize: Style.fontSizeXXXL * 3 * scaling
visible: !trackArt.visible
anchors.centerIn: parent
}
}
// -------------------------
// Track metadata
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXS * scaling
NText {
visible: MediaService.trackTitle !== ""
text: MediaService.trackTitle
font.pointSize: Style.fontSizeM * scaling
font.weight: Style.fontWeightBold
elide: Text.ElideRight
wrapMode: Text.Wrap
maximumLineCount: 2
Layout.fillWidth: true
}
NText {
visible: MediaService.trackArtist !== ""
text: MediaService.trackArtist
color: Color.mOnSurface
font.pointSize: Style.fontSizeXS * scaling
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
visible: MediaService.trackAlbum !== ""
text: MediaService.trackAlbum
color: Color.mOnSurface
font.pointSize: Style.fontSizeXS * scaling
elide: Text.ElideRight
Layout.fillWidth: true
}
}
}
// -------------------------
// Progress slider (uses shared NSlider behavior like BarTab)
Item {
id: progressWrapper
visible: (MediaService.currentPlayer && MediaService.trackLength > 0)
Layout.fillWidth: true
height: Style.baseWidgetSize * 0.5 * scaling
// Local preview while dragging
property real localSeekRatio: -1
// Track the last ratio we actually sent to the backend to avoid redundant seeks
property real lastSentSeekRatio: -1
// Minimum change required to issue a new seek during drag
property real seekEpsilon: 0.01
property real progressRatio: {
if (!MediaService.currentPlayer || MediaService.trackLength <= 0)
return 0
const r = MediaService.currentPosition / MediaService.trackLength
if (isNaN(r) || !isFinite(r))
return 0
return Math.max(0, Math.min(1, r))
}
property real effectiveRatio: (MediaService.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio
// Debounced backend seek during drag
Timer {
id: seekDebounce
interval: 75
repeat: false
onTriggered: {
if (MediaService.isSeeking && progressWrapper.localSeekRatio >= 0) {
const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio))
if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) {
MediaService.seekByRatio(next)
progressWrapper.lastSentSeekRatio = next
}
}
}
}
NSlider {
id: progressSlider
anchors.fill: parent
from: 0
to: 1
stepSize: 0
snapAlways: false
enabled: MediaService.trackLength > 0 && MediaService.canSeek
heightRatio: 0.65
onMoved: {
progressWrapper.localSeekRatio = value
seekDebounce.restart()
}
onPressedChanged: {
if (pressed) {
MediaService.isSeeking = true
progressWrapper.localSeekRatio = value
MediaService.seekByRatio(value)
progressWrapper.lastSentSeekRatio = value
} else {
seekDebounce.stop()
MediaService.seekByRatio(value)
MediaService.isSeeking = false
progressWrapper.localSeekRatio = -1
progressWrapper.lastSentSeekRatio = -1
}
}
}
// While not dragging, bind slider to live progress
// during drag, let the slider manage its own value
Binding {
target: progressSlider
property: "value"
value: progressWrapper.progressRatio
when: !MediaService.isSeeking
}
}
// -------------------------
// Media controls
RowLayout {
spacing: Style.marginM * scaling
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
// Previous button
NIconButton {
icon: "media-prev"
tooltipText: "Previous media"
visible: MediaService.canGoPrevious
onClicked: MediaService.canGoPrevious ? MediaService.previous() : {}
}
// Play/Pause button
NIconButton {
icon: MediaService.isPlaying ? "media-pause" : "media-play"
tooltipText: MediaService.isPlaying ? "Pause" : "Play"
visible: (MediaService.canPlay || MediaService.canPause)
onClicked: (MediaService.canPlay || MediaService.canPause) ? MediaService.playPause() : {}
}
// Next button
NIconButton {
icon: "media-next"
tooltipText: "Next media"
visible: MediaService.canGoNext
onClicked: MediaService.canGoNext ? MediaService.next() : {}
}
}
}
Loader {
active: Settings.data.audio.visualizerType == "linear" && MediaService.isPlaying
Layout.alignment: Qt.AlignHCenter
sourceComponent: LinearSpectrum {
width: 300 * scaling
height: 80 * scaling
values: CavaService.values
fillColor: Color.mPrimary
Layout.alignment: Qt.AlignHCenter
}
}
Loader {
active: Settings.data.audio.visualizerType == "mirrored" && MediaService.isPlaying
Layout.alignment: Qt.AlignHCenter
sourceComponent: MirroredSpectrum {
width: 300 * scaling
height: 80 * scaling
values: CavaService.values
fillColor: Color.mPrimary
Layout.alignment: Qt.AlignHCenter
}
}
Loader {
active: Settings.data.audio.visualizerType == "wave" && MediaService.isPlaying
Layout.alignment: Qt.AlignHCenter
sourceComponent: WaveSpectrum {
width: 300 * scaling
height: 80 * scaling
values: CavaService.values
fillColor: Color.mPrimary
Layout.alignment: Qt.AlignHCenter
}
}
}
}
@@ -0,0 +1,60 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.UPower
import qs.Commons
import qs.Services
import qs.Widgets
// Power Profiles: performance, balanced, eco
NBox {
property real spacing: 0
// Centralized service
readonly property bool hasPP: PowerProfileService.available
RowLayout {
id: powerRow
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: spacing
Item {
Layout.fillWidth: true
}
// Performance
NIconButton {
icon: PowerProfileService.getIcon(PowerProfile.Performance)
tooltipText: `Set "${PowerProfileService.getName(PowerProfile.Performance)}" power profile`
enabled: hasPP
opacity: enabled ? Style.opacityFull : Style.opacityMedium
colorBg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Color.mPrimary : Color.mSurfaceVariant
colorFg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Color.mOnPrimary : Color.mPrimary
onClicked: PowerProfileService.setProfile(PowerProfile.Performance)
}
// Balanced
NIconButton {
icon: PowerProfileService.getIcon(PowerProfile.Balanced)
tooltipText: `Set "${PowerProfileService.getName(PowerProfile.Balanced)}" power profile`
enabled: hasPP
opacity: enabled ? Style.opacityFull : Style.opacityMedium
colorBg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Color.mPrimary : Color.mSurfaceVariant
colorFg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Color.mOnPrimary : Color.mPrimary
onClicked: PowerProfileService.setProfile(PowerProfile.Balanced)
}
// Eco
NIconButton {
icon: PowerProfileService.getIcon(PowerProfile.PowerSaver)
tooltipText: `Set "${PowerProfileService.getName(PowerProfile.PowerSaver)}" power profile`
enabled: hasPP
opacity: enabled ? Style.opacityFull : Style.opacityMedium
colorBg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Color.mPrimary : Color.mSurfaceVariant
colorFg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Color.mOnPrimary : Color.mPrimary
onClicked: PowerProfileService.setProfile(PowerProfile.PowerSaver)
}
Item {
Layout.fillWidth: true
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import qs.Modules.Settings
import qs.Modules.ControlCenter
import qs.Commons
import qs.Services
import qs.Widgets
// Header card with avatar, user and quick actions
NBox {
id: root
property string uptimeText: "--"
RowLayout {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
NImageCircled {
width: Style.baseWidgetSize * 1.25 * scaling
height: Style.baseWidgetSize * 1.25 * scaling
imagePath: Settings.data.general.avatarImage
fallbackIcon: "person"
borderColor: Color.mPrimary
borderWidth: Math.max(1, Style.borderM * scaling)
}
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS * scaling
NText {
text: Quickshell.env("USER") || "user"
font.weight: Style.fontWeightBold
font.capitalization: Font.Capitalize
}
NText {
text: `System uptime: ${uptimeText}`
font.pointSize: Style.fontSizeS * scaling
color: Color.mOnSurfaceVariant
}
}
RowLayout {
spacing: Style.marginS * scaling
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Item {
Layout.fillWidth: true
}
NIconButton {
icon: "settings"
tooltipText: "Open settings"
onClicked: {
settingsPanel.requestedTab = SettingsPanel.Tab.General
settingsPanel.open()
}
}
NIconButton {
id: powerButton
icon: "power"
tooltipText: "Power panel"
onClicked: {
powerPanel.open()
controlCenterPanel.close()
}
}
NIconButton {
id: closeButton
icon: "close"
tooltipText: "Close side panel"
onClicked: {
controlCenterPanel.close()
}
}
}
}
// ----------------------------------
// Uptime
Timer {
interval: 60000
repeat: true
running: true
onTriggered: uptimeProcess.running = true
}
Process {
id: uptimeProcess
command: ["cat", "/proc/uptime"]
running: true
stdout: StdioCollector {
onStreamFinished: {
var uptimeSeconds = parseFloat(this.text.trim().split(' ')[0])
uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds)
uptimeProcess.running = false
}
}
}
function updateSystemInfo() {
uptimeProcess.running = true
}
}
@@ -0,0 +1,56 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Services
import qs.Widgets
// Unified system card: monitors CPU, temp, memory, disk
NBox {
id: root
ColumnLayout {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Style.marginS * scaling
anchors.rightMargin: Style.marginS * scaling
anchors.topMargin: Style.marginXS * scaling
anchors.bottomMargin: Style.marginM * scaling
spacing: Style.marginS * scaling
NCircleStat {
value: SystemStatService.cpuUsage
icon: "cpu-usage"
flat: true
contentScale: 0.8
width: 72 * scaling
height: 68 * scaling
}
NCircleStat {
value: SystemStatService.cpuTemp
suffix: "°C"
icon: "cpu-temperature"
flat: true
contentScale: 0.8
width: 72 * scaling
height: 68 * scaling
}
NCircleStat {
value: SystemStatService.memPercent
icon: "memory"
flat: true
contentScale: 0.8
width: 72 * scaling
height: 68 * scaling
}
NCircleStat {
value: SystemStatService.diskPercent
icon: "storage"
flat: true
contentScale: 0.8
width: 72 * scaling
height: 68 * scaling
}
}
}
@@ -0,0 +1,66 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Modules.Settings
import qs.Services
import qs.Widgets
// Utilities: record & wallpaper
NBox {
property real spacing: 0
RowLayout {
id: utilRow
anchors.fill: parent
anchors.margins: Style.marginS * scaling
spacing: spacing
Item {
Layout.fillWidth: true
}
// Screen Recorder
NIconButton {
icon: "camera-video"
enabled: ScreenRecorderService.isAvailable
tooltipText: ScreenRecorderService.isAvailable ? (ScreenRecorderService.isRecording ? "Stop screen recording" : "Start screen recording") : "Screen recorder is not installed"
colorBg: ScreenRecorderService.isRecording ? Color.mPrimary : Color.mSurfaceVariant
colorFg: ScreenRecorderService.isRecording ? Color.mOnPrimary : Color.mPrimary
onClicked: {
if (!ScreenRecorderService.isAvailable)
return
ScreenRecorderService.toggleRecording()
// If we were not recording and we just initiated a start, close the panel
if (!ScreenRecorderService.isRecording) {
var panel = PanelService.getPanel("controlCenterPanel")
panel?.close()
}
}
}
// Idle Inhibitor
NIconButton {
icon: IdleInhibitorService.isInhibited ? "keep-awake-on" : "keep-awake-off"
tooltipText: `${IdleInhibitorService.isInhibited ? "Disable" : "Enable"} keep awake`
colorBg: IdleInhibitorService.isInhibited ? Color.mPrimary : Color.mSurfaceVariant
colorFg: IdleInhibitorService.isInhibited ? Color.mOnPrimary : Color.mPrimary
onClicked: {
IdleInhibitorService.manualToggle()
}
}
// Wallpaper
NIconButton {
visible: Settings.data.wallpaper.enabled
icon: "wallpaper-selector"
tooltipText: "Left click: Open wallpaper selector.\nRight click: Set random wallpaper."
onClicked: PanelService.getPanel("wallpaperPanel")?.toggle(this)
onRightClicked: WallpaperService.setRandomWallpaper()
}
Item {
Layout.fillWidth: true
}
}
}
+129
View File
@@ -0,0 +1,129 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
import qs.Widgets
// Weather overview card (placeholder data)
NBox {
id: root
readonly property bool weatherReady: (LocationService.data.weather !== null)
ColumnLayout {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
RowLayout {
spacing: Style.marginS * scaling
NIcon {
Layout.alignment: Qt.AlignVCenter
icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : ""
font.pointSize: Style.fontSizeXXXL * 1.75 * scaling
color: Color.mPrimary
}
ColumnLayout {
spacing: Style.marginXXS * scaling
NText {
text: {
// Ensure the name is not too long if one had to specify the country
const chunks = Settings.data.location.name.split(",")
return chunks[0]
}
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
}
RowLayout {
NText {
visible: weatherReady
text: {
if (!weatherReady) {
return ""
}
var temp = LocationService.data.weather.current_weather.temperature
var suffix = "C"
if (Settings.data.location.useFahrenheit) {
temp = LocationService.celsiusToFahrenheit(temp)
var suffix = "F"
}
temp = Math.round(temp)
return `${temp}°${suffix}`
}
font.pointSize: Style.fontSizeXL * scaling
font.weight: Style.fontWeightBold
}
NText {
text: weatherReady ? `(${LocationService.data.weather.timezone_abbreviation})` : ""
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurfaceVariant
visible: LocationService.data.weather
}
}
}
}
NDivider {
visible: weatherReady
Layout.fillWidth: true
}
RowLayout {
visible: weatherReady
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
spacing: Style.marginL * scaling
Repeater {
model: weatherReady ? LocationService.data.weather.daily.time : []
delegate: ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: Style.marginL * scaling
NText {
text: {
var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/"))
return Qt.formatDateTime(weatherDate, "ddd")
}
color: Color.mOnSurface
Layout.alignment: Qt.AlignHCenter
}
NIcon {
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
icon: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index])
font.pointSize: Style.fontSizeXXL * 1.6 * scaling
color: Color.mPrimary
}
NText {
Layout.alignment: Qt.AlignHCenter
text: {
var max = LocationService.data.weather.daily.temperature_2m_max[index]
var min = LocationService.data.weather.daily.temperature_2m_min[index]
if (Settings.data.location.useFahrenheit) {
max = LocationService.celsiusToFahrenheit(max)
min = LocationService.celsiusToFahrenheit(min)
}
max = Math.round(max)
min = Math.round(min)
return `${max}°/${min}°`
}
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurfaceVariant
}
}
}
}
RowLayout {
visible: !weatherReady
Layout.fillWidth: true
Layout.alignment: Qt.AlignHCenter
NBusyIndicator {}
}
}
}
@@ -0,0 +1,82 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Modules.ControlCenter.Cards
import qs.Commons
import qs.Services
import qs.Widgets
NPanel {
id: root
preferredWidth: 460
preferredHeight: 734
panelKeyboardFocus: true
panelContent: Item {
id: content
property real cardSpacing: Style.marginL * scaling
// Layout content
ColumnLayout {
id: layout
x: content.cardSpacing
y: content.cardSpacing
width: parent.width - (2 * content.cardSpacing)
spacing: content.cardSpacing
// Cards (consistent inter-card spacing via ColumnLayout spacing)
ProfileCard {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(64 * scaling)
}
WeatherCard {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(220 * scaling)
}
// Middle section: media + stats column
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(310 * scaling)
spacing: content.cardSpacing
// Media card
MediaCard {
Layout.fillWidth: true
Layout.fillHeight: true
}
// System monitors combined in one card
SystemMonitorCard {
Layout.preferredWidth: Style.baseWidgetSize * 2.625 * scaling
Layout.fillHeight: true
}
}
// Bottom actions (two grouped rows of round buttons)
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: Math.max(60 * scaling)
spacing: content.cardSpacing
// Power Profiles switcher
PowerProfilesCard {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: content.cardSpacing
}
// Utilities buttons
UtilitiesCard {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: content.cardSpacing
}
}
}
}
}