diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index 2517550..85bd05a 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -4247,6 +4247,8 @@ const defaultLocales: LocaleTree = {
wallpaperFromPhotosDescription: 'Use one of your photos',
wallpaperFromCamera: 'Take Photo',
wallpaperFromCameraDescription: 'Create a new wallpaper',
+ wallpaperCustomUpload: 'Upload Custom Image',
+ wallpaperCustomUploadDescription: 'Use a verified HTTPS image link',
wallpaperHistory: 'Recent',
wallpaperCustom: 'Photo wallpaper',
wallpaperTarget: 'Wallpaper destination',
diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts
index de819b3..499ea73 100644
--- a/frontend/src/types/media.ts
+++ b/frontend/src/types/media.ts
@@ -1,6 +1,11 @@
export type MediaType = 'photo' | 'video'
export type GalleryFilter = 'all' | MediaType
+export type MediaConfig = {
+ customWallpaperUploadEnabled: boolean
+ videoBitrateKbps: number
+}
+
export type PhoneMedia = {
createdAt: number
favorite: boolean
diff --git a/frontend/src/ui/settings.css b/frontend/src/ui/settings.css
index 9cf773f..ff885f9 100644
--- a/frontend/src/ui/settings.css
+++ b/frontend/src/ui/settings.css
@@ -190,19 +190,81 @@ label.sky-settings-row__title {
}
.sky-settings-range-row__frame {
- padding-top: var(--sky-space-1);
- padding-bottom: var(--sky-space-1);
+ min-height: 88px;
+ padding-top: 12px;
+ padding-bottom: 14px;
+ align-items: stretch;
+ flex-direction: column;
+ gap: 10px;
}
.sky-settings-group--compact .sky-settings-row__frame,
.sky-settings-group--compact .sky-settings-range-row__frame {
- min-height: 48px;
+ min-height: 76px;
padding-top: 6px;
padding-bottom: 6px;
}
-.sky-settings-range-row .sky-range {
+.sky-settings-range-row__header,
+.sky-settings-range-row__control {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+}
+
+.sky-settings-range-row__header {
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.sky-settings-range-row__title,
+.sky-settings-range-row__value {
+ font-size: 17px;
+ line-height: 22px;
+}
+
+.sky-settings-range-row__title {
+ min-width: 0;
+ color: var(--sky-text);
+ overflow-wrap: anywhere;
+}
+
+.sky-settings-range-row__value {
+ flex: none;
+ color: var(--sky-muted);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.sky-settings-range-row__control {
+ min-height: 28px;
+ gap: 12px;
+}
+
+.sky-settings-range-row__control .sky-range {
width: 100%;
+ flex: 1 1 auto;
+}
+
+.sky-settings-range-row__endpoint {
+ width: 24px;
+ height: 28px;
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ color: var(--sky-muted);
+}
+
+.sky-settings-range-row__endpoint > svg {
+ width: 22px;
+ height: 22px;
+ display: block;
+}
+
+.sky-settings-range-row__endpoint--leading > svg {
+ width: 17px;
+ height: 17px;
}
.sky-settings-icon {
diff --git a/frontend/src/ui/settings/SkySettingsRangeRow.test.ts b/frontend/src/ui/settings/SkySettingsRangeRow.test.ts
index 9e9b2a6..6c89c8d 100644
--- a/frontend/src/ui/settings/SkySettingsRangeRow.test.ts
+++ b/frontend/src/ui/settings/SkySettingsRangeRow.test.ts
@@ -1,4 +1,4 @@
-import { createSSRApp } from 'vue'
+import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
@@ -26,7 +26,9 @@ describe('SkySettingsRangeRow', () => {
expect(html).toContain('min="25"')
expect(html).toContain('max="100"')
expect(html).toContain('step="5"')
- expect(html).toContain('75%')
+ expect(html).toContain('class="sky-settings-range-row__title">Brightness')
+ expect(html).toContain('class="sky-settings-range-row__value">75%')
+ expect(html).not.toContain('sky-range__caption')
})
it('uses the effective numeric value as its default visible label', async () => {
@@ -37,7 +39,9 @@ describe('SkySettingsRangeRow', () => {
const html = await renderToString(app)
- expect(html).toMatch(/class="sky-range__label">.*1\.25.*<\/span>/)
+ expect(html).toMatch(
+ /class="sky-settings-range-row__value">.*1\.25.*<\/span>/,
+ )
expect(html).not.toContain('aria-valuetext=')
})
@@ -51,7 +55,31 @@ describe('SkySettingsRangeRow', () => {
const html = await renderToString(app)
expect(html).toContain('aria-valuetext="75%"')
- expect(html).toMatch(/class="sky-range__label">.*75%.*<\/span>/)
+ expect(html).toMatch(
+ /class="sky-settings-range-row__value">.*75%.*<\/span>/,
+ )
+ })
+
+ it('supports decorative range endpoint icons without changing the label', async () => {
+ const app = createSSRApp({
+ render: () =>
+ h(
+ SkySettingsRangeRow,
+ { modelValue: 50, title: 'Volume', valueLabel: '50%' },
+ {
+ leading: () => h('svg', { 'data-endpoint': 'low' }),
+ trailing: () => h('svg', { 'data-endpoint': 'high' }),
+ },
+ ),
+ })
+
+ const html = await renderToString(app)
+
+ expect(html).toContain('sky-settings-range-row__endpoint--leading')
+ expect(html).toContain('sky-settings-range-row__endpoint--trailing')
+ expect(html).toContain('data-endpoint="low"')
+ expect(html).toContain('data-endpoint="high"')
+ expect(html).toContain('aria-label="Volume"')
})
it('exposes input, change, and numeric model update events', () => {
diff --git a/frontend/src/ui/settings/SkySettingsRangeRow.vue b/frontend/src/ui/settings/SkySettingsRangeRow.vue
index 8019199..8dbbfbd 100644
--- a/frontend/src/ui/settings/SkySettingsRangeRow.vue
+++ b/frontend/src/ui/settings/SkySettingsRangeRow.vue
@@ -53,21 +53,38 @@ const accessibleValue = computed(
:class="{ 'sky-settings-range-row--disabled': disabled }"
>
-
- {{ visibleValue }}
-
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue
index 34e19f4..3be9103 100644
--- a/frontend/src/views/apps/CameraApp.vue
+++ b/frontend/src/views/apps/CameraApp.vue
@@ -18,7 +18,12 @@ import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
-import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
+import type {
+ MediaConfig,
+ MediaType,
+ PhoneMedia,
+ UploadResult,
+} from '@/types/media'
import { SkySegmented, SkySegmentedButton } from '@/ui'
import { createGameView, type GameView } from '@/utils/gameView'
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
@@ -416,7 +421,7 @@ onMounted(() => {
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
- void nuiCall<{ videoBitrateKbps?: number }>('media:config').then(
+ void nuiCall('media:config').then(
(response) => {
if (response.success && response.data?.videoBitrateKbps) {
videoBitrateKbps.value = response.data.videoBitrateKbps
diff --git a/frontend/src/views/apps/GalleryApp.contract.test.ts b/frontend/src/views/apps/GalleryApp.contract.test.ts
index 173d5d9..8c8a898 100644
--- a/frontend/src/views/apps/GalleryApp.contract.test.ts
+++ b/frontend/src/views/apps/GalleryApp.contract.test.ts
@@ -12,6 +12,14 @@ const headerActions = source.slice(
)
describe('GalleryApp import action', () => {
+ it('opens a configured wallpaper upload and returns the imported photo directly', () => {
+ expect(source).toContain("route.query.wallpaperUpload === '1'")
+ expect(source).toContain("requestedMessageMedia.value === 'photo'")
+ expect(source).toContain('openImport()')
+ expect(source).toContain('messageMedia.complete(response.data)')
+ expect(source).toContain('await router.push(returnPath)')
+ })
+
it('keeps import available as a header button', () => {
expect(headerActions).toContain('(() => {
+const requestedMessageMedia = computed(() => {
const value = route.query.mediaAttachment ?? route.query.messageAttachment
return value === 'photo' || value === 'video' ? value : null
})
@@ -414,7 +415,19 @@ async function loadImportSources(): Promise {
if (isDevelopment && !developmentApiEnabled) return
const response = await nuiCall('media:import:sources')
if (!response.success || !response.data) return
- importSources.value = response.data.sources
+ const requestedType = requestedMessageMedia.value
+ importSources.value = requestedType
+ ? response.data.sources.filter((source) =>
+ source.mediaTypes.includes(requestedType),
+ )
+ : response.data.sources
+ if (
+ route.query.wallpaperUpload === '1' &&
+ requestedType === 'photo' &&
+ importSources.value.length > 0
+ ) {
+ openImport()
+ }
}
function selectImportSource(source: MediaImportSource): void {
@@ -473,6 +486,16 @@ async function commitUrlImport(): Promise {
}
media.value = mergeMedia(media.value, [response.data])
await fetchCounts()
+ if (
+ route.query.wallpaperUpload === '1' &&
+ requestedMessageMedia.value === 'photo'
+ ) {
+ const returnPath = messageMedia.complete(response.data)
+ if (returnPath) {
+ await router.push(returnPath)
+ return
+ }
+ }
closeImport()
showToast(phone.t('Apps.photos.import.linkCompleted'))
}
diff --git a/frontend/src/views/apps/SettingsApp.contract.test.ts b/frontend/src/views/apps/SettingsApp.contract.test.ts
index 6d0c784..6354524 100644
--- a/frontend/src/views/apps/SettingsApp.contract.test.ts
+++ b/frontend/src/views/apps/SettingsApp.contract.test.ts
@@ -19,6 +19,9 @@ describe('SettingsApp Sky UI contract', () => {
expect(source).toContain('')
+ expect(source).toContain('')
expect(source).toContain(
``,
)
@@ -36,9 +39,14 @@ describe('SettingsApp Sky UI contract', () => {
)
})
- it('offers photo, camera, recent, and two-column built-in wallpaper choices', () => {
+ it('offers photo, camera, configured custom upload, recent, and built-in wallpaper choices', () => {
expect(source).toContain("openWallpaperMedia('photos')")
expect(source).toContain("openWallpaperMedia('camera')")
+ expect(source).toContain('customWallpaperUploadAvailable')
+ expect(source).toContain('openWallpaperCustomUpload')
+ expect(source).toContain("nuiCall('media:config')")
+ expect(source).toContain('nuiCall')
+ expect(source).toContain("'media:import:sources'")
expect(source).toContain('`settings:wallpaper:${wallpaperTarget.value}`')
expect(source).toContain("wallpaperTarget === 'home'")
expect(source).toContain("wallpaperTarget === 'lock'")
diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue
index 9a0da9c..f534566 100644
--- a/frontend/src/views/apps/SettingsApp.vue
+++ b/frontend/src/views/apps/SettingsApp.vue
@@ -17,7 +17,9 @@ import {
Signal,
Smartphone,
Sun,
+ Upload,
UserRound,
+ Volume1,
Volume2,
Wifi,
} from 'lucide-vue-next'
@@ -47,6 +49,7 @@ import type {
LaunchablePhoneAppDefinition,
LaunchablePhoneAppId,
} from '@/types/apps'
+import type { MediaConfig, MediaImportSources } from '@/types/media'
import {
filterMailAddressInput,
MAIL_ADDRESS_INPUT_MAX_LENGTH,
@@ -159,6 +162,8 @@ const simEjectOpened = ref(false)
const factoryResetting = ref(false)
const factoryResetProgress = ref(0)
const wallpaperTarget = ref('home')
+const customWallpaperUploadAvailable = ref(false)
+let wallpaperMediaConfigLoaded = false
const selectedFrameColor = computed(
() => PHONE_FRAME_COLORS[phone.preferences.settings.frame],
)
@@ -299,6 +304,44 @@ function openWallpaperMedia(app: 'photos' | 'camera'): void {
})
}
+async function loadWallpaperMediaConfig(): Promise {
+ if (wallpaperMediaConfigLoaded) return
+ wallpaperMediaConfigLoaded = true
+
+ const configResponse = await nuiCall('media:config')
+ if (
+ !configResponse.success ||
+ configResponse.data?.customWallpaperUploadEnabled !== true
+ ) {
+ return
+ }
+
+ const sourcesResponse = await nuiCall(
+ 'media:import:sources',
+ )
+ customWallpaperUploadAvailable.value =
+ sourcesResponse.success &&
+ Boolean(
+ sourcesResponse.data?.sources.some((source) =>
+ source.mediaTypes.includes('photo'),
+ ),
+ )
+}
+
+function openWallpaperCustomUpload(): void {
+ const target = wallpaperTarget.value
+ mediaPicker.begin(
+ `settings:wallpaper:${target}`,
+ 'photo',
+ `/apps/settings?wallpaper=1&wallpaperTarget=${target}`,
+ 1,
+ )
+ void router.push({
+ path: '/apps/photos',
+ query: { mediaAttachment: 'photo', wallpaperUpload: '1' },
+ })
+}
+
function wallpaperPreviewStyle(
entry: WallpaperHistoryEntry,
): Record | undefined {
@@ -404,6 +447,7 @@ function openView(view: SubmenuView): void {
if (view === 'security') {
passcodeLength.value = phone.security.length ?? 6
}
+ if (view === 'wallpaper') void loadWallpaperMediaConfig()
activeView.value = view
scrollPageToTop()
}
@@ -700,6 +744,7 @@ onMounted(() => {
activeView.value = 'wallpaper'
wallpaperTarget.value =
route.query.wallpaperTarget === 'lock' ? 'lock' : 'home'
+ void loadWallpaperMediaConfig()
}
const selectedPhoto = mediaPicker.consume(
@@ -1198,7 +1243,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('ringtoneVolume', $event)
"
- />
+ >
+
+
+
{
@update:model-value="
updateNumberPreference('notificationVolume', $event)
"
- />
+ >
+
+
+
@@ -1458,7 +1509,10 @@ onBeforeUnmount(() => {
@update:model-value="
updateNumberPreference('screenBrightness', $event)
"
- />
+ >
+
+
+
@@ -1471,7 +1525,10 @@ onBeforeUnmount(() => {
:max="PHONE_SCALE_MAX"
:step="PHONE_SCALE_STEP"
@update:model-value="updateNumberPreference('phoneScale', $event)"
- />
+ >
+
+
+
@@ -1541,6 +1598,24 @@ onBeforeUnmount(() => {
}}
+
{
transform: scale(0.97);
}
+.settings-wallpaper-actions > .settings-wallpaper-actions__custom {
+ grid-column: 1 / -1;
+ min-height: 92px;
+}
+
.settings-wallpaper-actions > button:focus-visible,
.settings-wallpaper-history > button:focus-visible,
.settings-wallpaper-choice:focus-visible {
@@ -1951,8 +2031,8 @@ onBeforeUnmount(() => {
display: grid;
place-items: center;
border-radius: 12px;
- background: var(--sky-app-accent-soft);
- color: var(--sky-app-accent);
+ background: var(--sky-surface-muted);
+ color: #fff;
}
.settings-wallpaper-actions__icon > svg {
diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua
index 89534c6..3e93232 100644
--- a/sky_phone/config/locales/de.lua
+++ b/sky_phone/config/locales/de.lua
@@ -45,6 +45,8 @@ local translations = {
["Share"] = "Teilen",
["Start"] = "Starten",
["Stop"] = "Stoppen",
+ ["Upload Custom Image"] = "Eigenes Bild hochladen",
+ ["Use a verified HTTPS image link"] = "Verwende einen geprüften HTTPS-Bildlink",
["Use"] = "Verwenden",
["Yes"] = "Ja",
["Today"] = "Heute",
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index a8dc934..eb2763a 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -1930,6 +1930,7 @@ Locales["en"] = {
back = "Settings", wallpaperPicker = "Sky Wallpapers", deviceInformation = "Device Information",
wallpaperFromPhotos = "Choose from Photos", wallpaperFromPhotosDescription = "Use one of your photos",
wallpaperFromCamera = "Take Photo", wallpaperFromCameraDescription = "Create a new wallpaper",
+ wallpaperCustomUpload = "Upload Custom Image", wallpaperCustomUploadDescription = "Use a verified HTTPS image link",
wallpaperHistory = "Recent", wallpaperCustom = "Photo wallpaper",
wallpaperTarget = "Wallpaper destination", wallpaperHomeScreen = "Home Screen", wallpaperLockScreen = "Lock Screen",
imei = "IMEI", linkedDevices = "Linked Devices", thisDevice = "This Phone", removeDevice = "Remove Device",
diff --git a/sky_phone/config/media.lua b/sky_phone/config/media.lua
index cee79a0..b5eacaa 100644
--- a/sky_phone/config/media.lua
+++ b/sky_phone/config/media.lua
@@ -64,6 +64,12 @@ Config.Media = {
},
},
+ Wallpaper = {
+ -- Shows the verified HTTPS media import directly in the wallpaper picker.
+ -- Camera and Photos wallpaper sources are not affected by this setting.
+ CustomUploadEnabled = true,
+ },
+
Photo = {
Encoding = "jpg",
Quality = 0.95,
diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua
index 5167d97..8d6a1a5 100644
--- a/sky_phone/source/server/media.lua
+++ b/sky_phone/source/server/media.lua
@@ -643,9 +643,12 @@ Bridge.Callbacks.Register("sky_phone:media:config", function(source)
if not owner then
return error_response
end
+ local wallpaper_config = type(Config.Media.Wallpaper) == "table" and Config.Media.Wallpaper or {}
return {
success = true,
data = {
+ customWallpaperUploadEnabled = wallpaper_config.CustomUploadEnabled == true
+ and Config.Media.Import.Enabled == true,
videoBitrateKbps = tonumber(Config.Media.Video.BitrateKbps) or 1500,
},
}