diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e17b61..174aad3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,7 +36,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -set(ENABLE_MODULES "plugin;shell" CACHE STRING "Modules to build/install") +set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install") set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir") set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") @@ -150,7 +150,6 @@ endif() if("plugin" IN_LIST ENABLE_MODULES) add_subdirectory(Plugins) - endif() if("shell" IN_LIST ENABLE_MODULES) @@ -167,3 +166,22 @@ if("shell" IN_LIST ENABLE_MODULES) # Greeter install(DIRECTORY Greeter/ DESTINATION "${INSTALL_GREETERCONFDIR}") endif() + +if("m3shapes" IN_LIST ENABLE_MODULES) + message(STATUS "Fetching M3Shapes module") + include(FetchContent) + set(M3SHAPES_REV bdc327b29f95394a732baf3c9b19658ba23755b6) + FetchContent_Declare( + m3shapes_external + GIT_REPOSITORY https://github.com/soramanew/m3shapes.git + GIT_TAG ${M3SHAPES_REV} + SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/m3shapes-${M3SHAPES_REV}" + ) + FetchContent_MakeAvailable(m3shapes_external) + message(STATUS "Done fetching M3Shapes module") + + # Fix m3shapes wrong rpath + if(TARGET m3shapesplugin) + set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN") + endif() +endif() diff --git a/Components/LoadingIndicator.qml b/Components/LoadingIndicator.qml new file mode 100644 index 0000000..94ac1d5 --- /dev/null +++ b/Components/LoadingIndicator.qml @@ -0,0 +1,114 @@ +import QtQuick +import Quickshell +import M3Shapes +import qs.Config + +MaterialShape { + id: root + + property bool animated: true + property real cRotation + property bool containsIcon + property real dampingRatio: 0.6 + property real lRotation + property int morphAnimRotation: 60 + property real morphScale: 0.14 + property alias rotateAnimDuration: rotateAnim.duration + property int shapeIndex + property list shapes: { + if (containsIcon) + return [MaterialShape.SoftBurst, MaterialShape.Cookie9Sided, MaterialShape.Pill, MaterialShape.Sunny, MaterialShape.Cookie4Sided, MaterialShape.Oval]; + return [MaterialShape.SoftBurst, MaterialShape.Cookie9Sided, MaterialShape.Pentagon, MaterialShape.Pill, MaterialShape.Sunny, MaterialShape.Cookie4Sided, MaterialShape.Oval]; + } + readonly property real springDuration: { + const wn = Math.sqrt(stiffness); + const r = -dampingRatio * wn; + const c = 1 / Math.sqrt(1 - dampingRatio * dampingRatio); + return Math.log(visibilityThreshold / c) / r; + } + readonly property real springMaxVelocity: { + const wn = Math.sqrt(stiffness); + const factor = Math.exp(-z * Math.acos(z) / Math.sqrt(1 - z * z)); + return wn * factor; + } + property bool springSettled: true + property real stiffness: 180 + property real thisLRotation + property real visibilityThreshold: 0.075 + + function spring(t: real): var { + const wn = Math.sqrt(stiffness); + const za = dampingRatio * wn; + + const wd = wn * Math.sqrt(1 - dampingRatio * dampingRatio); + const r = za / wd; + const pos = 1 - Math.exp(-za * t) * (Math.cos(wd * t) + r * Math.sin(wd * t)); + const vel = Math.exp(-za * t) * (wn * wn / wd) * Math.sin(wd * t); + + return [pos, vel]; + } + + color: DynamicColors.palette.m3primary + implicitSize: 38 + toShape: shapes[0] + + RotationAnimation on cRotation { + id: rotateAnim + + duration: 4666 + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + running: root.animated + to: 360 + } + Behavior on color { + CAnim { + } + } + + ElapsedTimer { + id: timer + } + + FrameAnimation { + running: root.animated && !root.springSettled + + onTriggered: { + const t = timer.elapsed(); + + if (t >= root.springDuration) { + root.springSettled = true; + } else { + const [pos, vel] = root.spring(t); + root.morphProgress = Math.min(1, pos); // Overshooting the morph looks weird + root.thisLRotation = pos * root.morphAnimRotation; + root.scale = 1 + vel * root.morphScale / root.springMaxVelocity; + } + } + } + + Timer { + interval: 650 + repeat: true + running: root.animated + triggeredOnStart: true + + onTriggered: { + root.beginBatchUpdate(); + root.fromShape = root.toShape; + root.shapeIndex = (root.shapeIndex + 1) % root.shapes.length; + root.toShape = root.shapes[root.shapeIndex]; + root.morphProgress = 0; + + root.rotation = root.rotation; + root.lRotation = (root.lRotation + root.thisLRotation) % 360; + root.thisLRotation = 0; + root.rotation = Qt.binding(() => root.cRotation + root.lRotation + root.thisLRotation); + + root.springSettled = false; + timer.restart(); + root.endBatchUpdate(); + } + } +} diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml index 5392d95..c563a0b 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -6,25 +6,55 @@ import qs.Effects CustomListView { id: root - property real bottomFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 + property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real fadeAmount: 0.1 - property real topFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 + readonly property bool horizontal: orientation === ListView.Horizontal + property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 - function fadeShouldBeActive(isStart: bool): bool { - // When content is smaller than flickable size, hide fade when rebound starts - if (contentHeight + topMargin + bottomMargin < height && rebound.running && ((isStart ? verticalOvershoot > 0 : verticalOvershoot < 0))) - return false; - - if (isStart) - return visibleArea.yPosition > 0; - return visibleArea.yPosition + visibleArea.heightRatio < 1; + function contentSize(): real { + return horizontal ? contentWidth : contentHeight; } - flickableDirection: Flickable.VerticalFlick - layer.enabled: true - orientation: ListView.Vertical + function fadeShouldBeActive(isStart: bool): bool { + // When content is smaller than flickable size, hide fade when rebound starts. + if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) { + return false; + } - Behavior on bottomFadeOpacity { + if (isStart) + return visibleStart() > 0; + + return visibleStart() + visibleRatio() < 1; + } + + function marginEnd(): real { + return horizontal ? rightMargin : bottomMargin; + } + + function marginStart(): real { + return horizontal ? leftMargin : topMargin; + } + + function overshootStart(): real { + return horizontal ? horizontalOvershoot : verticalOvershoot; + } + + function viewportSize(): real { + return horizontal ? width : height; + } + + function visibleRatio(): real { + return horizontal ? visibleArea.widthRatio : visibleArea.heightRatio; + } + + function visibleStart(): real { + return horizontal ? visibleArea.xPosition : visibleArea.yPosition; + } + + flickableDirection: horizontal ? Flickable.HorizontalFlick : Flickable.VerticalFlick + layer.enabled: true + + Behavior on endFadeOpacity { Anim { type: Anim.SlowEffects } @@ -40,10 +70,10 @@ CustomListView { visible: false gradient: Gradient { - orientation: Gradient.Vertical + orientation: root.horizontal ? Gradient.Horizontal : Gradient.Vertical GradientStop { - color: Qt.rgba(0, 0, 0, root.topFadeOpacity) + color: Qt.rgba(0, 0, 0, root.startFadeOpacity) position: 0 } @@ -58,13 +88,13 @@ CustomListView { } GradientStop { - color: Qt.rgba(0, 0, 0, root.bottomFadeOpacity) + color: Qt.rgba(0, 0, 0, root.endFadeOpacity) position: 1 } } } } - Behavior on topFadeOpacity { + Behavior on startFadeOpacity { Anim { type: Anim.SlowEffects } diff --git a/Components/WavyTopRect.qml b/Components/WavyTopRect.qml new file mode 100644 index 0000000..b0dee4c --- /dev/null +++ b/Components/WavyTopRect.qml @@ -0,0 +1,46 @@ +import QtQuick +import QtQuick.Shapes +import qs.Config + +Shape { + id: root + + property real amplitude: 3 + property color color: DynamicColors.palette.m3surfaceContainer + readonly property real waveHeight: amplitude * 2 + property int waves: 4 + + asynchronous: true + preferredRendererType: Shape.CurveRenderer + + ShapePath { + fillColor: root.color + strokeColor: "transparent" + strokeWidth: 0 + + Behavior on fillColor { + CAnim { + } + } + + PathSvg { + path: { + const w = root.width; + const h = root.height; + const a = root.amplitude; + const n = Math.max(1, root.waves); + const wl = w / n; + const half = wl / 2; + + let d = `M 0,${a} `; + for (let i = 0; i < n; ++i) { + const x = i * wl; + d += `Q ${x + half / 2},${-a} ${x + half},${a} `; + d += `Q ${x + half + half / 2},${3 * a} ${x + wl},${a} `; + } + d += `L ${w},${h} L 0,${h} Z`; + return d; + } + } + } +} diff --git a/Helpers/Players.qml b/Helpers/Players.qml index 3f72fcf..d67d5f9 100644 --- a/Helpers/Players.qml +++ b/Helpers/Players.qml @@ -1,12 +1,12 @@ pragma Singleton +import QtQml import Quickshell import Quickshell.Io import Quickshell.Services.Mpris -import QtQml import ZShell -import qs.Config import qs.Components +import qs.Config Singleton { id: root @@ -15,7 +15,24 @@ Singleton { readonly property list list: Mpris.players.values property alias manualActive: props.manualActive + function getArtUrl(player: MprisPlayer): string { + if (!player) + return ""; + if (player.trackArtUrl) + return player.trackArtUrl; + + const url = player.metadata["xesam:url"] ?? ""; + if (url.startsWith("https://www.youtube.com/watch")) { + // Fallback for youtube + const id = url.match(/[?&]v=([\w-]{11})/)?.[1]; + return id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : ""; + } + return ""; + } + function getIdentity(player: MprisPlayer): string { + if (!player) + return ""; const alias = Config.services.playerAliases.find(a => a.from === player.identity); return alias?.to ?? player.identity; } @@ -25,9 +42,12 @@ Singleton { if (!Config.utilities.toasts.nowPlaying) { return; } + if (root.active.trackArtist != "" && root.active.trackTitle != "") { + Toaster.toast(qsTr("Now Playing"), qsTr("%1 - %2").arg(root.active.trackArtist).arg(root.active.trackTitle), "music_note"); + } } - target: active + target: root.active } PersistentProperties { @@ -38,8 +58,10 @@ Singleton { reloadableId: "players" } + // qmllint disable unresolved-type CustomShortcut { description: "Toggle media playback" + // qmllint enable unresolved-type name: "mediaToggle" onPressed: { @@ -49,8 +71,10 @@ Singleton { } } + // qmllint disable unresolved-type CustomShortcut { description: "Previous track" + // qmllint enable unresolved-type name: "mediaPrev" onPressed: { @@ -60,8 +84,10 @@ Singleton { } } + // qmllint disable unresolved-type CustomShortcut { description: "Next track" + // qmllint enable unresolved-type name: "mediaNext" onPressed: { @@ -71,8 +97,10 @@ Singleton { } } + // qmllint disable unresolved-type CustomShortcut { description: "Stop media playback" + // qmllint enable unresolved-type name: "mediaStop" onPressed: root.active?.stop() diff --git a/Helpers/Weather.qml b/Helpers/Weather.qml index 480c119..d0d47ce 100644 --- a/Helpers/Weather.qml +++ b/Helpers/Weather.qml @@ -1,7 +1,7 @@ pragma Singleton -import Quickshell import QtQuick +import Quickshell import ZShell import qs.Config @@ -12,15 +12,15 @@ Singleton { property var cc property string city readonly property string description: cc?.weatherDesc ?? qsTr("No weather") - readonly property string feelsLike: `${cc?.feelsLikeC ?? 0}°C` + readonly property string feelsLike: formatTemp(cc?.feelsLikeC) property list forecast property list hourlyForecast readonly property int humidity: cc?.humidity ?? 0 readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" property string loc - readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), "h:mm") : "--:--" - readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), "h:mm") : "--:--" - readonly property string temp: `${cc?.tempC ?? 0}°C` + readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" + readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" + readonly property string temp: formatTemp(cc?.tempC) readonly property real windSpeed: cc?.windSpeed ?? 0 function fetchCityFromCoords(coords: string): void { @@ -29,29 +29,48 @@ Singleton { return; } - const [lat, lon] = coords.split(","); - const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`; - Requests.get(url, text => { + const [lat, lon] = coords.split(",").map(s => s.trim()); + const lang = Qt.locale().name.split("_")[0] || "en"; + + const fallbackToBigDataCloud = () => { + const fallbackUrl = `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=${lang}`; + Requests.get(fallbackUrl, text => { + const geo = JSON.parse(text); + const geoCity = geo.city || geo.locality; + if (geoCity) { + city = fixCityName(geoCity); + cachedCities.set(coords, city); + } else { + city = "Unknown City"; + } + }); + }; + + const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson&accept-language=${lang}`; + Requests.get(nominatimUrl, text => { const geo = JSON.parse(text).features?.[0]?.properties.geocoding; if (geo) { const geoCity = geo.type === "city" ? geo.name : geo.city; - city = geoCity; - cachedCities.set(coords, geoCity); - } else { - city = "Unknown City"; + if (geoCity) { + city = fixCityName(geoCity); + cachedCities.set(coords, city); + return; + } } - }); + fallbackToBigDataCloud(); + }, fallbackToBigDataCloud); } function fetchCoordsFromCity(cityName: string): void { - const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`; + const lang = Qt.locale().name.split("_")[0] || "en"; + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=${lang}&format=json`; Requests.get(url, text => { const json = JSON.parse(text); if (json.results && json.results.length > 0) { const result = json.results[0]; loc = result.latitude + "," + result.longitude; - city = result.name; + city = fixCityName(result.name); } else { loc = ""; reload(); @@ -72,25 +91,21 @@ Singleton { cc = { weatherCode: json.current.weather_code, weatherDesc: getWeatherCondition(json.current.weather_code), - tempC: Math.round(json.current.temperature_2m), - tempF: Math.round(toFahrenheit(json.current.temperature_2m)), - feelsLikeC: Math.round(json.current.apparent_temperature), - feelsLikeF: Math.round(toFahrenheit(json.current.apparent_temperature)), + tempC: json.current.temperature_2m, + feelsLikeC: json.current.apparent_temperature, humidity: json.current.relative_humidity_2m, windSpeed: json.current.wind_speed_10m, isDay: json.current.is_day, - sunrise: json.daily.sunrise[0], - sunset: json.daily.sunset[0] + sunrise: json.daily.sunrise[0].replace("T", " "), + sunset: json.daily.sunset[0].replace("T", " ") }; const forecastList = []; for (let i = 0; i < json.daily.time.length; i++) forecastList.push({ - date: json.daily.time[i], - maxTempC: Math.round(json.daily.temperature_2m_max[i]), - maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])), - minTempC: Math.round(json.daily.temperature_2m_min[i]), - minTempF: Math.round(toFahrenheit(json.daily.temperature_2m_min[i])), + date: json.daily.time[i].replace(/-/g, "/"), + maxTempC: json.daily.temperature_2m_max[i], + minTempC: json.daily.temperature_2m_min[i], weatherCode: json.daily.weather_code[i], icon: Icons.getWeatherIcon(json.daily.weather_code[i]) }); @@ -99,7 +114,8 @@ Singleton { const hourlyList = []; const now = new Date(); for (let i = 0; i < json.hourly.time.length; i++) { - const time = new Date(json.hourly.time[i]); + const time = new Date(json.hourly.time[i].replace("T", " ")); + if (time < now) continue; @@ -107,7 +123,7 @@ Singleton { timestamp: json.hourly.time[i], hour: time.getHours(), tempC: Math.round(json.hourly.temperature_2m[i]), - tempF: Math.round(toFahrenheit(json.hourly.temperature_2m[i])), + precipChance: json.hourly.precipitation_probability[i], weatherCode: json.hourly.weather_code[i], icon: Icons.getWeatherIcon(json.hourly.weather_code[i]) }); @@ -116,6 +132,59 @@ Singleton { }); } + function fixCityName(cityName: string): string { + if (!cityName) + return ""; + const mapping = { + // Polish + "Poznan": "Poznań", + "Wroclaw": "Wrocław", + "Krakow": "Kraków", + "Gdansk": "Gdańsk", + "Lodz": "Łódź", + "Rzeszow": "Rzeszów", + "Torun": "Toruń", + "Bialystok": "Białystok", + "Czestochowa": "Częstochowa", + "Plock": "Płock", + "Ruda Slaska": "Ruda Śląska", + "Dabrowa Gornicza": "Dąbrowa Górnicza", + "Elblag": "Elbląg", + "Gorzow Wielkopolski": "Gorzów Wielkopolski", + "Zielona Gora": "Zielona Góra", + "Slupsk": "Słupsk", + + // German + "Munchen": "München", + "Koln": "Köln", + "Dusseldorf": "Düsseldorf", + "Nurnberg": "Nürnberg", + + // French & Spanish & Portuguese + "Sao Paulo": "São Paulo", + "Montreal": "Montréal", + "Quebec": "Québec", + "Bogota": "Bogotá", + "Medellin": "Medellín", + "Cordoba": "Córdoba", + + // Turkish + "Istanbul": "İstanbul", + "Izmir": "İzmir", + + // Scandinavian & others + "Malmo": "Malmö", + "Goteborg": "Göteborg", + "Zurich": "Zürich", + "Geneve": "Genève" + }; + return mapping[cityName] || cityName; + } + + function formatTemp(temp: var): string { + return Config.services.useFahrenheit ? `${temp !== undefined ? Math.round(toFahrenheit(temp)) : "--"}°F` : `${temp !== undefined ? Math.round(temp) : "--"}°C`; + } + function getWeatherCondition(code: string): string { const conditions = { "0": "Clear", @@ -154,9 +223,9 @@ Singleton { if (!loc || loc.indexOf(",") === -1) return ""; - const [lat, lon] = loc.split(","); + const [lat, lon] = loc.split(",").map(s => s.trim()); const baseUrl = "https://api.open-meteo.com/v1/forecast"; - const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"]; + const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m,precipitation_probability", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"]; return baseUrl + "?" + params.join("&"); } @@ -189,7 +258,14 @@ Singleton { onLocChanged: fetchWeatherData() - // Refresh current location hourly + Connections { + function onWeatherLocationChanged(): void { + root.reload(); + } + + target: Config.services + } + Timer { interval: 3600000 // 1 hour repeat: true @@ -200,6 +276,5 @@ Singleton { ElapsedTimer { id: timer - } } diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml index bf86285..77a32f5 100644 --- a/Modules/Lock/Content.qml +++ b/Modules/Lock/Content.qml @@ -4,76 +4,66 @@ import qs.Components import qs.Helpers import qs.Config -RowLayout { +CustomClippingRect { id: root required property var lock - spacing: Appearance.spacing.large * 2 + implicitHeight: layout.implicitHeight + implicitWidth: layout.implicitWidth + radius: Appearance.rounding.large - ColumnLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.normal + RowLayout { + id: layout - CustomRect { + anchors.fill: parent + spacing: Appearance.spacing.large * 2 + + ColumnLayout { Layout.fillWidth: true - color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: weather.implicitHeight - radius: Appearance.rounding.small - topLeftRadius: Appearance.rounding.large + spacing: Appearance.spacing.normal WeatherInfo { id: weather + Layout.fillWidth: true rootHeight: root.height } - } - - CustomRect { - Layout.fillWidth: true - color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: resources.implicitHeight - radius: Appearance.rounding.small Resources { id: resources + Layout.fillWidth: true } - } - - CustomClippingRect { - Layout.fillHeight: true - Layout.fillWidth: true - bottomLeftRadius: Appearance.rounding.large - color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.small Media { id: media + Layout.fillHeight: true + Layout.fillWidth: true lock: root.lock } } - } - Center { - lock: root.lock - } + Center { + lock: root.lock + } - ColumnLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.normal - - CustomRect { - Layout.fillHeight: true + ColumnLayout { Layout.fillWidth: true - bottomRightRadius: Appearance.rounding.large - color: DynamicColors.tPalette.m3surfaceContainer - radius: Appearance.rounding.small - topRightRadius: Appearance.rounding.large + spacing: Appearance.spacing.normal - NotifDock { - lock: root.lock + CustomRect { + Layout.fillHeight: true + Layout.fillWidth: true + bottomRightRadius: Appearance.rounding.large + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.small + topRightRadius: Appearance.rounding.large + + NotifDock { + lock: root.lock + } } } } diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index 4349bf8..505e3ed 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -1,201 +1,110 @@ -pragma ComponentBehavior: Bound - import QtQuick import QtQuick.Layouts -import qs.Modules +import Quickshell +import ZShell.Components import qs.Components -import qs.Helpers import qs.Config +import qs.Helpers -Item { +CustomClippingRect { id: root required property var lock - anchors.fill: parent + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + radius: Appearance.rounding.small + + FadeImage { + id: image - Image { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectCrop layer.enabled: true opacity: status === Image.Ready ? 1 : 0 - source: Players.active?.trackArtUrl ?? "" - sourceSize.height: height - sourceSize.width: width - - layer.effect: OpacityMask { - maskSource: mask + source: Players.getArtUrl(Players.active) + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); } + Behavior on opacity { Anim { - duration: Appearance.anim.durations.extraLarge + type: Anim.StandardExtraLarge } } - } - Rectangle { - id: mask - - anchors.fill: parent - layer.enabled: true - visible: false - - gradient: Gradient { - orientation: Gradient.Horizontal - - GradientStop { - color: Qt.rgba(0, 0, 0, 0.5) - position: 0 - } - - GradientStop { - color: Qt.rgba(0, 0, 0, 0.2) - position: 0.4 - } - - GradientStop { - color: Qt.rgba(0, 0, 0, 0) - position: 0.8 - } + CustomRect { + anchors.fill: parent + color: DynamicColors.palette.m3surface + opacity: 0.7 } } ColumnLayout { id: layout - anchors.fill: parent - anchors.margins: Appearance.padding.large - - CustomText { - Layout.bottomMargin: Appearance.spacing.larger - Layout.topMargin: Appearance.padding.large - color: DynamicColors.palette.m3onSurfaceVariant - font.family: Appearance.font.family.mono - font.weight: 500 - text: qsTr("Now playing") - } + anchors.left: parent.left + anchors.margins: Appearance.padding.extraLarge + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.extraSmall CustomText { Layout.fillWidth: true animate: true color: DynamicColors.palette.m3primary elide: Text.ElideRight - font.family: Appearance.font.family.mono - font.pointSize: Appearance.font.size.large - font.weight: 600 + font.pointSize: Appearance.font.size.medium horizontalAlignment: Text.AlignHCenter - text: Players.active?.trackArtist ?? qsTr("No media") + text: (Players.active?.trackTitle ?? qsTr("Nothing playing")) || qsTr("Unknown track") } CustomText { Layout.fillWidth: true animate: true + color: DynamicColors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.family: Appearance.font.family.mono - font.pointSize: Appearance.font.size.larger + font.pointSize: Appearance.font.size.small horizontalAlignment: Text.AlignHCenter - text: Players.active?.trackTitle ?? qsTr("No media") + text: (Players.active?.trackArtist ?? qsTr("Try playing some music!")) || qsTr("Unknown artist") } - RowLayout { + ButtonRow { Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: Appearance.padding.large - Layout.topMargin: Appearance.spacing.large * 1.2 - spacing: Appearance.spacing.large - - PlayerControl { - function onClicked(): void { - if (Players.active?.canGoPrevious) - Players.active.previous(); - } + Layout.topMargin: Appearance.spacing.small + spacing: Appearance.spacing.extraSmall + IconButton { + enabled: Players.active?.canGoPrevious icon: "skip_previous" + isRound: true + shapeMorph: true + type: IconButton.Tonal + + onClicked: Players.active?.previous() } - PlayerControl { - function onClicked(): void { - if (Players.active?.canTogglePlaying) - Players.active.togglePlaying(); - } + IconButton { + checked: Players.active?.isPlaying ?? false + enabled: Players.active?.canTogglePlaying + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + implicitWidth: implicitHeight + Appearance.padding.largeIncreased * 2 + isRound: true + shapeMorph: true - active: Players.active?.isPlaying ?? false - animate: true - icon: active ? "pause" : "play_arrow" - level: active ? 2 : 1 - set_color: "Primary" + onClicked: Players.active?.togglePlaying() } - PlayerControl { - function onClicked(): void { - if (Players.active?.canGoNext) - Players.active.next(); - } - + IconButton { + enabled: Players.active?.canGoNext icon: "skip_next" - } - } - } + isRound: true + shapeMorph: true + type: IconButton.Tonal - component PlayerControl: CustomRect { - id: control - - property bool active - property alias animate: controlIcon.animate - property alias icon: controlIcon.text - property int level: 1 - property string set_color: "Secondary" - - function onClicked(): void { - } - - Layout.preferredWidth: implicitWidth + (controlState.pressed ? Appearance.padding.normal * 2 : active ? Appearance.padding.small * 2 : 0) - color: active ? DynamicColors.palette[`m3${set_color.toLowerCase()}`] : DynamicColors.palette[`m3${set_color.toLowerCase()}Container`] - implicitHeight: controlIcon.implicitHeight + Appearance.padding.normal * 2 - implicitWidth: controlIcon.implicitWidth + Appearance.padding.large * 2 - radius: active || controlState.pressed ? Appearance.rounding.small : Appearance.rounding.normal - - Behavior on Layout.preferredWidth { - Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial - } - } - Behavior on radius { - Anim { - duration: Appearance.anim.durations.expressiveFastSpatial - easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial - } - } - - Elevation { - anchors.fill: parent - level: controlState.containsMouse && !controlState.pressed ? control.level + 1 : control.level - radius: parent.radius - z: -1 - } - - StateLayer { - id: controlState - - color: control.active ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`] - - onClicked: { - control.onClicked(); - } - } - - MaterialIcon { - id: controlIcon - - anchors.centerIn: parent - color: control.active ? DynamicColors.palette[`m3on${control.set_color}`] : DynamicColors.palette[`m3on${control.set_color}Container`] - fill: control.active ? 1 : 0 - font.pointSize: Appearance.font.size.large - - Behavior on fill { - Anim { - } + onClicked: Players.active?.next() } } } diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml index dfb1bba..4dbccf2 100644 --- a/Modules/Lock/Resources.qml +++ b/Modules/Lock/Resources.qml @@ -1,81 +1,82 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Layouts +import M3Shapes import ZShell.Services import qs.Components -import qs.Helpers import qs.Config +import qs.Effects -GridLayout { +CustomRect { id: root - anchors.left: parent.left - anchors.margins: Appearance.padding.large - anchors.right: parent.right - columnSpacing: Appearance.spacing.large - columns: 2 - rowSpacing: Appearance.spacing.large - rows: 1 + readonly property real fontScale: { + const diff = width / 391 - 1; // 391 is the width at 1080 height screen + return 1 + Math.pow(Math.abs(diff), 0.8) * Math.sign(diff); + } + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + radius: Appearance.rounding.small + + ServiceRef { + service: Cpu + } ServiceRef { service: Memory } ServiceRef { - service: Cpu + service: Storage } - Resource { - Layout.bottomMargin: Appearance.padding.large - Layout.topMargin: Appearance.padding.large - fgColor: DynamicColors.palette.m3primary - icon: "memory" - value: Cpu.percentage + RowLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.large + + Resource { + id: cpu + + fgColor: DynamicColors.palette.m3primary + icon: "memory" + value: Cpu.percentage + } + + Resource { + fgColor: DynamicColors.palette.m3tertiary + icon: "memory_alt" + value: Memory.percentage + } + + Resource { + fgColor: DynamicColors.palette.m3secondary + icon: "hard_disk" + value: Storage.percentage + } } - Resource { - Layout.bottomMargin: Appearance.padding.large - Layout.topMargin: Appearance.padding.large - fgColor: DynamicColors.palette.m3secondary - icon: "memory_alt" - value: Memory.percentage - } - - component Resource: CustomRect { + component Resource: CircularProgress { id: res - required property color fgColor required property string icon - required property real value Layout.fillWidth: true - color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - implicitHeight: width - radius: Appearance.rounding.large + implicitSize: width - Behavior on value { + Behavior on clampedVal { Anim { - duration: Appearance.anim.durations.large } } - CircularProgress { - id: circ - - anchors.fill: parent - bgColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) - fgColor: res.fgColor - padding: Appearance.padding.large * 3 - strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal - value: res.value - } - MaterialIcon { - id: icon - anchors.centerIn: parent color: res.fgColor - font.pointSize: (circ.arcRadius * 0.7) || 1 - font.weight: 600 + font.pointSize: Appearance.font.size.extraLarge text: res.icon } } diff --git a/Modules/Lock/Weather/BriefInfo.qml b/Modules/Lock/Weather/BriefInfo.qml new file mode 100644 index 0000000..d6244c3 --- /dev/null +++ b/Modules/Lock/Weather/BriefInfo.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config +import qs.Helpers + +ColumnLayout { + id: root + + required property int rootHeight + + spacing: Appearance.spacing.extraSmall + + CustomText { + Layout.alignment: Qt.AlignHCenter + animate: true + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + text: Weather.description + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small + + CustomText { + id: temp + + animate: true + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.large + text: Weather.temp + } + + MaterialIcon { + animate: true + color: DynamicColors.palette.m3secondary + text: Weather.icon + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + animate: true + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.large + text: qsTr("Feels like %1").arg(Weather.temp) + visible: root.rootHeight > 550 + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + animate: true + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.medium + text: { + const today = Weather.forecast[0]; + return qsTr("High %1 • Low %2").arg(Weather.formatTemp(today?.maxTempC)).arg(Weather.formatTemp(today?.minTempC)); + } + visible: root.rootHeight > 550 + } +} diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml new file mode 100644 index 0000000..222c858 --- /dev/null +++ b/Modules/Lock/Weather/Forecast.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Layouts +import M3Shapes +import ZShell +import qs.Components +import qs.Helpers +import qs.Config + +CustomRect { + id: root + + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + implicitHeight: header.anchors.margins + header.implicitHeight + Appearance.spacing.small + layout.implicitHeight + layout.anchors.bottomMargin + radius: Appearance.rounding.small + + RowLayout { + id: header + + anchors.left: parent.left + anchors.margins: Appearance.padding.largeIncreased + anchors.top: parent.top + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) + font.pointSize: Appearance.font.size.medium + text: "schedule" + } + + CustomText { + id: title + + font.pointSize: Appearance.font.size.medium + text: qsTr("Hourly forecast") + } + } + + VerticalFadeListView { + id: layout + + anchors.bottom: parent.bottom + anchors.bottomMargin: Appearance.padding.largeIncreased + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.right: parent.right + implicitHeight: contentItem.childrenRect.height + model: Weather.hourlyForecast + orientation: VerticalFadeListView.Horizontal + spacing: Appearance.spacing.normal + + delegate: ColumnLayout { + id: hour + + readonly property var cond: modelData + required property int index + required property var modelData + + spacing: Appearance.spacing.extraSmall + + MaterialShape { + Layout.alignment: Qt.AlignHCenter + color: Qt.alpha(DynamicColors.palette.m3primary, hour.index === 0 ? 1 : 0) + implicitSize: temp.implicitHeight + Appearance.padding.normal * 2 + shape: MaterialShape.Cookie4Sided + + Behavior on color { + CAnim { + } + } + + CustomText { + id: temp + + anchors.centerIn: parent + color: hour.index === 0 ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + font.pointSize: Appearance.font.size.medium + text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F + } + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.extraLarge + text: hour.cond.icon + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3primary + text: hour.cond.precipChance + "%" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.extraSmall + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: hour.index === 0 ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") + } + } + } +} diff --git a/Modules/Lock/WeatherInfo.qml b/Modules/Lock/WeatherInfo.qml index 56bcdb5..f7434ee 100644 --- a/Modules/Lock/WeatherInfo.qml +++ b/Modules/Lock/WeatherInfo.qml @@ -1,162 +1,23 @@ -pragma ComponentBehavior: Bound - import QtQuick -import QtQuick.Layouts +import qs.Modules.Lock.Weather +import qs.Config import qs.Components import qs.Helpers -import qs.Config -ColumnLayout { +CustomRect { id: root required property int rootHeight + readonly property bool showForecast: rootHeight >= 700 - anchors.left: parent.left - anchors.margins: Appearance.padding.large * 2 - anchors.right: parent.right - spacing: Appearance.spacing.small - - Loader { - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: -Appearance.padding.large - Layout.topMargin: Appearance.padding.large * 2 - active: root.rootHeight > 610 - visible: active - - sourceComponent: CustomText { - color: DynamicColors.palette.m3primary - font.pointSize: Appearance.font.size.extraLarge - font.weight: 500 - text: qsTr("Weather") - } - } - - RowLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.large - - MaterialIcon { - animate: true - color: DynamicColors.palette.m3secondary - font.pointSize: Appearance.font.size.extraLarge * 2.5 - text: Weather.icon - } - - ColumnLayout { - spacing: Appearance.spacing.small - - CustomText { - Layout.fillWidth: true - animate: true - color: DynamicColors.palette.m3secondary - elide: Text.ElideRight - font.pointSize: Appearance.font.size.large - font.weight: 500 - text: Weather.description - } - - CustomText { - Layout.fillWidth: true - animate: true - color: DynamicColors.palette.m3onSurfaceVariant - elide: Text.ElideRight - font.pointSize: Appearance.font.size.normal - text: qsTr("Humidity: %1%").arg(Weather.humidity) - } - } - - Loader { - Layout.rightMargin: Appearance.padding.smaller - active: root.width > 400 - visible: active - - sourceComponent: ColumnLayout { - spacing: Appearance.spacing.small - - CustomText { - Layout.fillWidth: true - animate: true - color: DynamicColors.palette.m3primary - elide: Text.ElideLeft - font.pointSize: Appearance.font.size.extraLarge - font.weight: 500 - horizontalAlignment: Text.AlignRight - text: Weather.temp - } - - CustomText { - Layout.fillWidth: true - animate: true - color: DynamicColors.palette.m3outline - elide: Text.ElideLeft - font.pointSize: Appearance.font.size.smaller - horizontalAlignment: Text.AlignRight - text: qsTr("Feels like: %1").arg(Weather.feelsLike) - } - } - } - } - - Loader { - id: forecastLoader - - Layout.bottomMargin: Appearance.padding.large * 2 - Layout.fillWidth: true - Layout.topMargin: Appearance.spacing.smaller - active: root.rootHeight > 820 - visible: active - - sourceComponent: RowLayout { - spacing: Appearance.spacing.large - - Repeater { - model: { - const forecast = Weather.hourlyForecast; - const count = root.width < 320 ? 3 : root.width < 400 ? 4 : 5; - if (!forecast) - return Array.from({ - length: count - }, () => null); - - return forecast.slice(0, count); - } - - ColumnLayout { - id: forecastHour - - required property var modelData - - Layout.fillWidth: true - spacing: Appearance.spacing.small - - CustomText { - Layout.fillWidth: true - color: DynamicColors.palette.m3outline - font.pointSize: Appearance.font.size.larger - horizontalAlignment: Text.AlignHCenter - text: { - const hour = forecastHour.modelData?.hour ?? 0; - return hour > 12 ? `${(hour - 12).toString().padStart(2, "0")} PM` : `${hour.toString().padStart(2, "0")} AM`; - } - } - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - font.pointSize: Appearance.font.size.extraLarge * 1.5 - font.weight: 500 - text: forecastHour.modelData?.icon ?? "cloud_alert" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: DynamicColors.palette.m3secondary - font.pointSize: Appearance.font.size.larger - text: Config.services.useFahrenheit ? `${forecastHour.modelData?.tempF ?? 0}°F` : `${forecastHour.modelData?.tempC ?? 0}°C` - } - } - } - } + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: { + const base = brief.implicitHeight + brief.anchors.topMargin; + if (showForecast) + return base + Appearance.spacing.large + forecast.implicitHeight + forecast.anchors.margins; + return base + brief.anchors.topMargin; } + radius: Appearance.rounding.small Timer { interval: 900000 // 15 minutes @@ -166,4 +27,27 @@ ColumnLayout { onTriggered: Weather.reload() } + + BriefInfo { + id: brief + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Appearance.padding.extraLarge + rootHeight: root.rootHeight + } + + Loader { + id: forecast + + active: root.showForecast + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.right: parent.right + asynchronous: true + + sourceComponent: Forecast { + } + } } diff --git a/Modules/Settings/Common/WallItem.qml b/Modules/Settings/Common/WallItem.qml index e681b47..ad2aa3b 100644 --- a/Modules/Settings/Common/WallItem.qml +++ b/Modules/Settings/Common/WallItem.qml @@ -32,6 +32,31 @@ Item { implicitHeight: width radius: Appearance.rounding.large + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: img.status === Image.Ready ? 0 : 1 + + Behavior on opacity { + Anim { + } + } + sourceComponent: CustomRect { + color: DynamicColors.palette.m3primaryContainer + implicitHeight: loadingIndicator.implicitSize + Appearance.padding.large * 2 + implicitWidth: loadingIndicator.implicitSize + Appearance.padding.large * 2 + radius: Appearance.rounding.full + + LoadingIndicator { + id: loadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: Math.min(imgWrapper.width, imgWrapper.height) * 0.3 + } + } + } + Image { id: img diff --git a/Modules/Settings/Pages/Panels/Bar/BarClock.qml b/Modules/Settings/Pages/Panels/Bar/BarClock.qml index 6501dec..d1f7fb4 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarClock.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarClock.qml @@ -48,7 +48,6 @@ PageBase { Layout.topMargin: Appearance.spacing.extraSmall / 2 - parent.spacing active: root.clockFormats.find(item => item.value === Config.general.dateFormat) first: true - last: true menuItems: root.clockFormats settingAnchor: "bar-clock-format" subtext: qsTr("Change how time is displayed in the widget") @@ -58,5 +57,15 @@ PageBase { Config.general.dateFormat = item.value; } } + + ToggleRow { + checked: Config.services.useTwelveHourClock + last: true + settingAnchor: "bar-clock-twelve-hour" + subtext: qsTr("Format timestamps for twelve or twenty-four hours in UI") + text: qsTr("Twelve hour clock") + + onToggled: Config.services.useTwelveHourClock = checked + } } }