Compare commits

...

10 Commits

Author SHA1 Message Date
Kuldeep Singh
2aeb816d8d
Merge 7b85ab1617 into 07ad7124a9 2026-10-29 17:18:58 +00:00
Kingkor Roy Tirtho
07ad7124a9
Merge pull request #3173 from team-spotube/dev
Dev
2026-10-29 17:36:52 +06:00
Kingkor Roy Tirtho
fa9b42cf10 cd(build): add support for building WebView native libraries on Linux and Windows 2026-10-29 16:03:55 +06:00
Kingkor Roy Tirtho
8d4ea82a7e fix(build): add headless mode for Linux and update CI to prepare dependencies 2026-10-29 15:01:43 +06:00
Kingkor Roy Tirtho
eb972c8633 fix(dependencies): update Android target SDK and various library versions 2026-10-29 11:25:22 +06:00
Kingkor Roy Tirtho
aca3aeb592 fix(theme): follow the OS dark mode on desktop
Compose's isSystemInDarkTheme never updates under the Tao window, so Theme.SYSTEM
stayed light. Use Nucleus' darkmode-detector on JVM and keep Compose's check on mobile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:53:47 +06:00
Kingkor Roy Tirtho
5b36edd259 fix(windows): make VLC playback work through the stream proxy
The proxy copied every upstream header, duplicating Content-Length, which VLC
rejects. Forward only the range/validator headers. Bump vlcj-bundler to 0.2.1,
which bundles the Matroska demuxer needed for WebM. Make StreamingUrlRepository's
platform injectable and add VLC log switch via DebugConfig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
ea01dec6ea fix(settings): palette swatches follow the active light/dark appearance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
79266fd483 fix(windows): window not spawning due to early COM initialization 2026-10-27 16:35:27 +06:00
Kuldeep Singh
7b85ab1617
Create app_pa.arb
Punjabi (pa) translation
2025-01-02 17:46:20 +05:30
18 changed files with 796 additions and 82 deletions

View File

@ -12,6 +12,19 @@ on:
- stable
- beta
- nightly
platform:
description: 'Platform to build (nightly only; stable and beta always build everything)'
required: true
type: choice
default: 'all'
options:
- all
- android
- linux-x86_64
- linux-aarch64
- windows
- macos
- ios
dry_run:
description: 'Dry run (build & upload artifacts only, skip creating release)'
required: false
@ -29,16 +42,94 @@ permissions:
# Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }}
cancel-in-progress: false
env:
COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview
COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d
# fix/linux-wayland-input
COMPOSE_WEBVIEW_COMMIT: b11ac748de1219f9432338c067a2c5dafef3072a
jobs:
validate-inputs:
name: Validate Inputs
runs-on: ubuntu-latest
steps:
- name: Single platform builds are nightly only
if: ${{ inputs.platform != 'all' && inputs.release_type != 'nightly' }}
run: |
echo "::error::Building a single platform is only supported for nightly releases"
exit 1
# ComposeNativeWebview's JNI backends are gitignored and only built for the host OS, so a
# publish from macOS alone ships a jar without the Linux / Windows WebView libraries (the
# WebView then renders nothing). Build them on their own OS and hand them to prepare-deps.
build-webview-natives:
name: WebView Natives (${{ matrix.name }})
needs: validate-inputs
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
include:
- name: linux-x64
os: ubuntu-latest
- name: linux-aarch64
os: ubuntu-24.04-arm
- name: windows-x64
os: windows-latest
steps:
- name: Checkout ComposeNativeWebview
uses: actions/checkout@v7
with:
repository: ${{ env.COMPOSE_WEBVIEW_REPO }}
ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }}
token: ${{ secrets.GITHUB_TOKEN }}
path: compose-webview
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
- name: Build Linux WebView native library
if: runner.os == 'Linux'
working-directory: compose-webview
run: |
sudo apt-get update
sudo apt-get install -y build-essential pkg-config libgtk-3-dev libcairo2-dev \
libsoup-3.0-dev libwebkit2gtk-4.1-dev
bash webview-compose/src/jvmMain/native/linux/build.sh
- name: Build Windows WebView native library
if: runner.os == 'Windows'
working-directory: compose-webview
shell: cmd
run: webview-compose\src\jvmMain\native\windows\build.bat
- name: Verify WebView native library
working-directory: compose-webview
shell: bash
run: |
case "${{ matrix.name }}" in
linux-*) files="${{ matrix.name }}/libcompose_webview_linux.so" ;;
windows-x64) files="win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll" ;;
esac
for f in $files; do
test -f "webview-compose/src/jvmMain/resources/nucleus/native/$f" || { echo "::error::Missing $f"; exit 1; }
done
- name: Upload WebView native library
uses: actions/upload-artifact@v7
with:
name: webview-natives-${{ matrix.name }}
path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native/
retention-days: 1
prepare-deps:
name: Prepare Dependencies
needs: [validate-inputs, build-webview-natives]
runs-on: macos-latest
steps:
- name: Checkout ComposeNativeWebview
@ -49,6 +140,15 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
path: compose-webview
# Downloaded after checkout, so they are newer than the native sources and
# the build keeps them instead of trying to rebuild on the wrong OS.
- name: Download WebView native libraries
uses: actions/download-artifact@v8
with:
pattern: webview-natives-*
merge-multiple: true
path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
@ -70,6 +170,16 @@ jobs:
chmod +x gradlew
./gradlew publishToMavenLocal
- name: Verify published JVM jar carries every WebView backend
run: |
jar=$(ls ~/.m2/repository/dev/nucleusframework/composewebview-jvm/*/composewebview-jvm-*.jar | grep -v -e sources -e javadoc | head -1)
contents=$(unzip -l "$jar")
for f in linux-x64/libcompose_webview_linux.so linux-aarch64/libcompose_webview_linux.so \
win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll \
darwin-aarch64/libcompose_webview_macos.dylib darwin-x64/libcompose_webview_macos.dylib; do
grep -q "nucleus/native/$f" <<< "$contents" || { echo "::error::$jar is missing $f"; exit 1; }
done
- name: Upload maven local
uses: actions/upload-artifact@v7
with:
@ -78,7 +188,8 @@ jobs:
build-android:
name: Android
needs: prepare-deps
needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }}
timeout-minutes: 120
runs-on: ubuntu-latest
steps:
@ -152,7 +263,8 @@ jobs:
build-linux:
name: Linux Desktop
needs: prepare-deps
needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }}
timeout-minutes: 120
runs-on: ubuntu-latest
steps:
@ -161,17 +273,17 @@ jobs:
with:
fetch-depth: 0
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Download maven local
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
@ -265,7 +377,8 @@ jobs:
build-linux-arm64:
name: Linux Desktop (aarch64)
needs: prepare-deps
needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }}
timeout-minutes: 120
runs-on: ubuntu-24.04-arm
steps:
@ -281,17 +394,17 @@ jobs:
with:
packages: tools platform-tools ndk;28.2.13676358
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Download maven local
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
@ -385,7 +498,8 @@ jobs:
build-windows:
name: Windows Desktop
needs: prepare-deps
needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }}
timeout-minutes: 120
runs-on: windows-latest
steps:
@ -448,7 +562,8 @@ jobs:
build-macos:
name: macOS Desktop
needs: prepare-deps
needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }}
timeout-minutes: 120
runs-on: macos-latest
steps:
@ -516,7 +631,8 @@ jobs:
build-ios:
name: iOS
needs: prepare-deps
needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }}
timeout-minutes: 120
runs-on: macos-latest
steps:
@ -585,8 +701,11 @@ jobs:
create-release:
name: Create GitHub Release
if: ${{ !inputs.dry_run }}
# Skipped platform jobs count as skipped, not failed, so a single platform build still releases
if: ${{ !inputs.dry_run && !cancelled() && !contains(needs.*.result, 'failure') }}
needs:
- validate-inputs
- prepare-deps
- build-android
- build-linux
- build-linux-arm64
@ -599,7 +718,6 @@ jobs:
steps:
- uses: actions/checkout@v7
# The build resolves its plugins from mavenLocal, so Gradle needs the same setup as the build jobs
- name: Download maven local
uses: actions/download-artifact@v8
with:
@ -639,6 +757,7 @@ jobs:
uses: actions/download-artifact@v8
- name: Generate release notes
if: ${{ inputs.platform == 'all' }}
run: |
echo "## Downloads" > release-notes.md
echo "" >> release-notes.md
@ -658,9 +777,12 @@ jobs:
tag_name: ${{ steps.vars.outputs.tag }}
# A new tag (stable) would otherwise point at the default branch instead of the built commit
target_commitish: ${{ github.sha }}
fail_on_unmatched_files: true
name: ${{ steps.vars.outputs.release_name }}
body_path: release-notes.md
# A single platform build only has its own artifacts
fail_on_unmatched_files: ${{ inputs.platform == 'all' }}
# A single platform build only replaces that platform's assets (same file names are overwritten),
# so leave the existing release's name and notes alone
name: ${{ inputs.platform == 'all' && steps.vars.outputs.release_name || '' }}
body_path: ${{ inputs.platform == 'all' && 'release-notes.md' || '' }}
draft: ${{ steps.vars.outputs.is_draft }}
prerelease: ${{ steps.vars.outputs.is_prerelease }}
files: |

View File

@ -366,6 +366,7 @@ kotlin {
implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray)
implementation(libs.nucleus.media.control)
implementation(libs.nucleus.darkmode.detector)
}
}
}
@ -645,6 +646,12 @@ tasks.withType<ComposeHotRun>().configureEach {
jvmArgs(
"--enable-native-access=ALL-UNNAMED"
)
// Linux: the hot-reload agent starts AWT before main(), and JBR's X11 toolkit pins GTK to
// XWayland, where the embedded WebKit WebView renders black. Tao owns the windows, so the
// reloadable JVM stays headless (DevTools runs in its own JVM and is unaffected).
if (System.getProperty("os.name").lowercase().contains("linux")) {
jvmArgs("-Djava.awt.headless=true")
}
systemProperty(
"compose.application.resources.dir",
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()

View File

@ -39,4 +39,6 @@ object DebugConfig {
* Keep it `false` when committing.
*/
const val FORCE_SUPPORT_REMINDER: Boolean = false
const val FORCE_VLC_LOGS: Boolean = false
}

View File

@ -425,7 +425,7 @@ internal class StreamProxy(
if (requestMethod == HttpMethod.Head) {
call.respond(object : OutgoingContent.NoContent() {
override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders
override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength
})
return
@ -446,7 +446,7 @@ internal class StreamProxy(
call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders
override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength
override val contentType: ContentType? =
downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse)
@ -594,4 +594,28 @@ internal class StreamProxy(
}
}.build()
}
}
private val PLAYER_PASSTHROUGH_HEADERS = listOf(
HttpHeaders.ContentRange,
HttpHeaders.AcceptRanges,
HttpHeaders.ETag,
HttpHeaders.LastModified,
)
/**
* The upstream headers a player actually needs. Copying them all makes Ktor emit duplicate
* Content-Length/Content-Type (also set through the OutgoingContent) and forwards hop-by-hop
* headers like `Connection: keep-alive`, which VLC rejects by closing the socket right after
* the response headers. Content-Range and Accept-Ranges must stay: without them the player
* treats a 206 as invalid and the stream as unseekable, and e.g. WebM can't be demuxed.
*/
internal fun Headers.passthroughForPlayer(): Headers {
val upstream = this
return HeadersBuilder().apply {
PLAYER_PASSTHROUGH_HEADERS.forEach { name ->
upstream.getAll(name)?.forEach { value -> append(name, value) }
}
}.build()
}

View File

@ -80,7 +80,8 @@ class StreamingUrlRepository(
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
private val platform: PlatformType = currentPlatform) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex()
@ -145,7 +146,7 @@ class StreamingUrlRepository(
} else {
stream.streams
}
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
}
@ -259,7 +260,7 @@ class StreamingUrlRepository(
"lowest wins):\n" +
streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " +
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
"${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
@ -268,8 +269,8 @@ class StreamingUrlRepository(
}
when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
!selected.isPlayableOn(currentPlatform) -> logger.w {
"[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " +
!selected.isPlayableOn(platform) -> logger.w {
"[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " +
"${selected.describeForLog()}, which the player will likely reject"
}
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
@ -424,12 +425,16 @@ internal fun selectPreferredAudioStream(
}
)
// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load.
private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska")
private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
// AVPlayer has no WebM/Ogg/Matroska demuxer.
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
/**
* Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android)
* handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
* Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
* everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
*/
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
isContainerPlayableOn(container, platform) &&
@ -437,7 +442,7 @@ internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS
container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +

View File

@ -17,7 +17,6 @@
package org.spotube.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@ -205,7 +204,7 @@ fun SpotubeTheme(
val isDarkTheme = when (settings.theme) {
Theme.LIGHT -> false
Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme()
Theme.SYSTEM -> isSystemDarkTheme()
}
val palette: MillenniumPalette = settings.palette

View File

@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.theming
import androidx.compose.runtime.Composable
/** Whether the OS is in dark mode, observed live; desktop can't rely on Compose's own check under Tao. */
@Composable
expect fun isSystemDarkTheme(): Boolean

View File

@ -108,14 +108,14 @@ data class JamBroker(
val connectionTimeoutSeconds: Int = 10)
/**
* opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, so it streams AAC.
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
* TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend).
*/
internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat {
val ios = platform == PlatformType.IOS
val aac = !isContainerPlayableOn("webm", platform)
return AudioFormat(
codec = if (ios) "aac" else "opus",
container = if (ios) "mp4" else "webm",
codec = if (aac) "aac" else "opus",
container = if (aac) "mp4" else "webm",
qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000),

View File

@ -44,11 +44,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.spotube.spotube.core.ui.theming.AppTheme
import org.spotube.spotube.core.ui.theming.isLightAppearance
import org.spotube.spotube.modules.settings.SettingsViewModel
import org.spotube.spotube.modules.settings.Theme
import org.spotube.spotube.modules.settings.UserSettings
@ -206,7 +206,8 @@ private fun PaletteSwatch(
label: String,
selected: Boolean,
onClick: () -> Unit) {
val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) }
val dark = !isLightAppearance()
val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) }
Box(
modifier = Modifier
.size(40.dp)
@ -222,7 +223,8 @@ private fun PaletteSwatch(
@Composable
private fun PaletteTrailingSwatch(palette: MillenniumPalette) {
val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) }
val dark = !isLightAppearance()
val scheme = remember(palette, dark) { MillenniumColorSchemes().resolve(palette, dark = dark) }
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically) {
@ -241,7 +243,7 @@ private fun PaletteTrailingSwatch(palette: MillenniumPalette) {
@Composable
private fun PalettePreview(palette: MillenniumPalette) {
val dark = AppTheme.colors.background.luminance() < 0.5f
val dark = !isLightAppearance()
MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) {
Column(
modifier = Modifier

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.server
import io.ktor.http.HeadersBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class StreamProxyHeadersTest {
// Lowercase names and values as googlevideo sends them
private val upstream = HeadersBuilder().apply {
append("accept-ranges", "bytes")
append("alt-svc", "h3=\":443\"")
append("cache-control", "private, max-age=21298")
append("connection", "keep-alive")
append("content-length", "3523553")
append("content-range", "bytes 0-3523552/3523553")
append("content-type", "audio/webm")
append("last-modified", "Tue, 18 Feb 2025 22:42:11 GMT")
}.build()
@Test
fun `keeps the headers a player needs to seek`() {
val forwarded = upstream.passthroughForPlayer()
assertEquals("bytes 0-3523552/3523553", forwarded["Content-Range"])
assertEquals("bytes", forwarded["Accept-Ranges"])
assertEquals("Tue, 18 Feb 2025 22:42:11 GMT", forwarded["Last-Modified"])
}
@Test
fun `drops headers that are duplicated or hop-by-hop`() {
val forwarded = upstream.passthroughForPlayer()
// Length and type are set on the OutgoingContent; forwarding them too emits them twice
assertNull(forwarded["Content-Length"])
assertNull(forwarded["Content-Type"])
assertNull(forwarded["Connection"])
assertNull(forwarded["Alt-Svc"])
assertNull(forwarded["Cache-Control"])
}
}

View File

@ -5,6 +5,7 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger
@ -99,7 +100,9 @@ class StreamingUrlRepositoryBehaviorTest {
audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings,
streamUrlCacheTtlMs = cacheTtlMs)
streamUrlCacheTtlMs = cacheTtlMs,
// Not the host's platform: Windows and iOS don't prefer opus/webm
platform = PlatformType.Linux)
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI)
@ -236,6 +239,11 @@ class StreamingUrlRepositoryBehaviorTest {
)
}
// The default format depends on the host platform (aac/mp4 on Windows and iOS)
h.settings.update {
copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "opus", container = "webm"))
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/opus.webm", first?.url)

View File

@ -35,8 +35,10 @@ class StreamingFormatDefaultsTest {
@Test
fun `other platforms keep opus in webm`() {
val format = defaultStreamingMusicFormat(PlatformType.Android)
assertEquals("opus" to "webm", format.codec to format.container)
listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform ->
val format = defaultStreamingMusicFormat(platform)
assertEquals("opus" to "webm", format.codec to format.container, platform.name)
}
}
@Test

View File

@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.DebugConfig
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.medialist.MediaList
@ -87,11 +88,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val _error = MutableSharedFlow<Throwable>(extraBufferCapacity = 1)
actual override val playerStateFlow: StateFlow<PlayerState> = _playerState.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentMediaItem.asStateFlow()
actual override val currentMediaItemFlow: StateFlow<MediaItem?> =
_currentMediaItem.asStateFlow()
actual override val playlistFlow: StateFlow<List<MediaItem>> = _playlist.asStateFlow()
actual override val durationFlow: StateFlow<Duration> = _duration.asStateFlow()
actual override val positionFlow: StateFlow<Duration> = _position.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> = _bufferingPosition.asStateFlow()
actual override val bufferingPositionFlow: StateFlow<Duration> =
_bufferingPosition.asStateFlow()
actual override val loopStateFlow: StateFlow<LoopState> = _loopState.asStateFlow()
actual override val shuffleModeFlow: StateFlow<Boolean> = _shuffleMode.asStateFlow()
actual override val playbackSpeedFlow: StateFlow<Float> = _playbackSpeed.asStateFlow()
@ -101,6 +104,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
init {
VLCBundleLoaderGenerated.getVerifiedPath()
val vlcLogArgs = if (DebugConfig.FORCE_VLC_LOGS) arrayOf("--verbose=2") else emptyArray()
mediaPlayerFactory = MediaPlayerFactory(
null as NativeDiscovery?,
"--no-video",
@ -108,8 +112,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
"--aout=any",
"--no-osd",
"--no-snapshot-preview",
// "--verbose=2"
// "--quiet"
*vlcLogArgs
)
audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) {
override fun opening(mediaPlayer: MediaPlayer) {
@ -122,7 +125,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
) {
val durationMs = mediaPlayer.status().length()
_bufferingPosition.tryEmit(
if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO)
if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO
)
_playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY)
}
@ -184,7 +188,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
override fun nextItem(
mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer,
item: uk.co.caprica.vlcj.media.MediaRef) {
item: uk.co.caprica.vlcj.media.MediaRef
) {
lock.withLock {
val targetIndex = pendingNextIndex ?: run {
val currentMrl = runCatching {
@ -336,7 +341,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
}
}
actual override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
actual override suspend fun load(
playlist: List<MediaItem>,
autoPlay: Boolean,
startPosition: Int
) {
lock.withLock {
if (disposed) return

View File

@ -0,0 +1,25 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.theming
import androidx.compose.runtime.Composable
import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
// The Tao window never feeds Compose's LocalSystemTheme, so ask the OS directly
@Composable
actual fun isSystemDarkTheme(): Boolean = isSystemInDarkMode()

View File

@ -132,6 +132,15 @@ private fun restoreWindowState(saved: PersistedWindowState?): PersistedWindowSta
return PersistedWindowState.of(size, saved.placement)
}
/** Runs [block] on a fresh thread and waits for it, rethrowing whatever it threw. */
private fun runOnWorkerThread(name: String, block: () -> Unit) {
var failure: Throwable? = null
val thread = Thread({ runCatching(block).onFailure { failure = it } }, name)
thread.start()
thread.join()
failure?.let { throw it }
}
@OptIn(ExperimentalComposeUiApi::class, FlowPreview::class)
fun main() {
// Sets compose.application.resources.dir in the native image. initKoin() creates the audio
@ -140,8 +149,19 @@ fun main() {
GraalVmInitializer.initialize()
AppImageEnvironment.apply()
FileKit.init(appId = "org.spotube.spotube")
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)
val initApp = {
initKoin()
NewPipeDownloader.init(KoinPathsProvider.paths)
}
// libvlc and the system media controls initialize COM as multithreaded on the calling thread. In the
// native image the main thread also creates the Tao window, whose OleInitialize needs a single-threaded
// apartment and panics with RPC_E_CHANGED_MODE, so on Windows Koin's eager singletons are created
// elsewhere. COM doesn't exist on macOS/Linux, where this stays on the main thread as before.
if (getPlatform().type == PlatformType.Windows) {
runOnWorkerThread("spotube-init", initApp)
} else {
initApp()
}
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
nucleusApplication(backend = NucleusBackend.Tao) {

View File

@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
@Composable
actual fun isSystemDarkTheme(): Boolean = isSystemInDarkTheme()

View File

@ -14,16 +14,12 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
[versions]
agp = "9.1.0"
android-compileSdk = "37"
android-minSdk = "28"
android-targetSdk = "36"
android-targetSdk = "37"
agp = "9.1.0"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.19.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0"
appdirs = "1.5.0"
cache4k = "0.14.0"
carApp = "1.7.0"
@ -34,12 +30,10 @@ composeShimmer = "1.5.0"
desugar_jdk_libs = "2.1.5"
haze = "2.0.0-alpha03"
jna = "5.18.1"
junit = "4.13.2"
kermit = "2.1.0"
kmpZip = "1.0.0"
kotlin = "2.4.21"
kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1"
kotlinx-serialization-json = "1.11.0"
millennium = "0.1.1"
mqttClient = "2.1.1"
@ -55,27 +49,22 @@ vlcjNative = "4.12.0"
ziplineVersion = "1.28.0"
ktor = "3.5.1"
dnssdkt = "1.1.0"
runner = "1.7.0"
core = "1.7.0"
filekit = "0.14.2"
compose-webview = "0.1.0-SNAPSHOT"
composeNativeTray = "2.1.6"
koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-alpha05"
compose-multiplatform-lifecycle = "2.10.0"
feather-icons = "1.1.1"
kotlinx-datetime = "0.8.0"
media3 = "1.10.1"
androidx-datastore = "1.2.1"
ksp = "2.3.12"
spotube-gradle = "0.1.2"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.2.0"
vlcjBundler = "0.2.1"
vanniktechMavenPublish = "0.37.0"
uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0"
mokkery = "3.5.1"
jmustache = "1.16"
@ -98,12 +87,6 @@ kmp-zip = { module = "no.synth:kmp-zip", version.ref = "kmpZip" }
kmp-zip-kotlinx = { module = "no.synth:kmp-zip-kotlinx", version.ref = "kmpZip" }
kmp-zip-okio = { module = "no.synth:kmp-zip-okio", version.ref = "kmpZip" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
@ -134,6 +117,7 @@ newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" }
nucleus-media-control = { module = "dev.nucleusframework:nucleus.media-control", version.ref = "nucleusNucleusApplication" }
nucleus-darkmode-detector = { module = "dev.nucleusframework:nucleus.darkmode-detector", version.ref = "nucleusNucleusApplication" }
nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleusNucleusApplication" }
nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" }
semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
@ -152,8 +136,6 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" }
filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" }
@ -169,7 +151,6 @@ feather-icons = { module = "br.com.devsrsouza.compose.icons:feather", version.re
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" }
kotlinx-io-okio = { module = "org.jetbrains.kotlinx:kotlinx-io-okio", version.ref = "kotlinx-io" }
cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography" }
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
@ -188,10 +169,8 @@ kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", versio
jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
mavenPublish = { id = "maven-publish" }
spotubeGradle = { id = "org.spotube.gradle-plugin", version.ref = "spotube-gradle" }
vlcjBundler = { id = "org.spotube.vlcj-bundler", version.ref = "vlcjBundler" }
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }

405
lib/l10n/app_pa.arb Normal file
View File

@ -0,0 +1,405 @@
{
"guest": "ਮਹਿਮਾਨ",
"browse": "ਬ੍ਰਾਊਜ਼ ਕਰੋ",
"search": "ਖੋਜੋ",
"library": "ਲਾਇਬ੍ਰੇਰੀ",
"lyrics": "ਗੀਤਾਂ ਦੇ ਬੋਲ",
"settings": "ਸੈਟਿੰਗਾਂ",
"genre_categories_filter": "ਸ਼੍ਰੇਣੀਆਂ ਜਾਂ ਸ਼ੈਲੀਆਂ ਨੂੰ ਛਾਣੋ...",
"genre": "ਸ਼ੈਲੀ",
"personalized": "ਵਿਅਕਤੀਗਤ",
"featured": "ਵਿਸ਼ੇਸ਼ ਰੂਪ ਨਾਲ ਪ੍ਰਦਰਸ਼ਿਤ",
"new_releases": "ਨਵੀਂ ਰਿਲੀਜ਼",
"songs": "ਗੀਤ",
"playing_track": "{track} ਚੱਲ ਰਿਹਾ ਹੈ",
"queue_clear_alert": "ਇਹ ਮੌਜੂਦਾ ਕਤਾਰ ਨੂੰ ਸਾਫ਼ ਕਰ ਦੇਵੇਗਾ। {track_length} ਟ੍ਰੈਕ ਹਟਾ ਦਿੱਤੇ ਜਾਣਗੇ\nਕੀ ਤੁਸੀਂ ਜਾਰੀ ਰੱਖਣਾ ਚਾਹੁੰਦੇ ਹੋ?",
"load_more": "ਹੋਰ ਲੋਡ ਕਰੋ",
"playlists": "ਪਲੇਸੂਚੀ",
"artists": "ਕਲਾਕਾਰ",
"albums": "ਐਲਬਮ",
"tracks": "ਟ੍ਰੈਕ",
"downloads": "ਡਾਊਨਲੋਡ",
"filter_playlists": "ਆਪਣੀਆਂ ਪਲੇਸੂਚੀਆਂ ਨੂੰ ਛਾਣੋ...",
"liked_tracks": "ਪਸੰਦੀਦਾ ਟ੍ਰੈਕ",
"liked_tracks_description": "ਤੁਹਾਡੇ ਸਾਰੇ ਪਸੰਦੀਦਾ ਟ੍ਰੈਕ",
"create_playlist": "ਪਲੇਸੂਚੀ ਬਣਾਉ",
"create_a_playlist": "ਇੱਕ ਪਲੇਸੂਚੀ ਬਣਾਉ",
"create": "ਬਣਾਉ",
"cancel": "ਰੱਦ ਕਰੋ",
"playlist_name": "ਪਲੇਸੂਚੀ ਦਾ ਨਾਮ",
"name_of_playlist": "ਪਲੇਸੂਚੀ ਦਾ ਨਾਮ",
"description": "ਵੇਰਵਾ",
"public": "ਜਨਤਕ",
"collaborative": "ਸਹਿਯੋਗੀ",
"search_local_tracks": "ਸਥਾਨਕ ਟ੍ਰੈਕ ਖੋਜੋ...",
"play": "ਚਲਾਉ",
"delete": "ਹਟਾਉ",
"none": "ਕੋਈ ਨਹੀਂ",
"sort_a_z": "A-Z ਦਿਖਾਉ",
"sort_z_a": "Z-A ਦਿਖਾਉ",
"sort_artist": "ਕਲਾਕਾਰ ਅਨੁਸਾਰ ਦਿਖਾਉ",
"sort_album": "ਐਲਬਮ ਅਨੁਸਾਰ ਦਿਖਾਉ",
"sort_tracks": "ਟ੍ਰੈਕ ਅਨੁਸਾਰ ਦਿਖਾਉ",
"currently_downloading": "ਹੁਣ ਡਾਊਨਲੋਡ ਹੋ ਰਿਹਾ ਹੈ ({tracks_length})",
"cancel_all": "ਸਾਰਿਆਂ ਨੂੰ ਰੱਦ ਕਰੋ",
"filter_artist": "ਕਲਾਕਾਰ ਨੂੰ ਛਾਣੋ...",
"followers": "{followers} ਫ਼ਾਲੋਅਰ",
"add_artist_to_blacklist": "ਕਲਾਕਾਰ ਨੂੰ ਕਾਲੀਸੂਚੀ ਵਿੱਚ ਜੋੜੋ",
"top_tracks": "ਚੋਟੀ ਦਾ ਟ੍ਰੈਕ",
"fans_also_like": "ਫੈਨਸ ਨੂੰ ਇਹ ਵੀ ਪਸੰਦ ਹੈ",
"loading": "ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ...",
"artist": "ਕਲਾਕਾਰ",
"blacklisted": "ਕਾਲੀਸੂਚੀ ਵਿੱਚ ਹੈ",
"following": "ਫ਼ਾਲੋ ਕਰ ਰਿਹਾ ਹੈ",
"follow": "ਫ਼ਾਲੋ ਕਰੋ",
"artist_url_copied": "ਕਲਾਕਾਰ ਯੂਆਰਐਲ ਕਲਿੱਪਬੋਰਡ ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ",
"added_to_queue": "{tracks} ਟ੍ਰੈਕ ਕਤਾਰ ਵਿੱਚ ਜੋੜੇ ਗਏ",
"filter_albums": "ਐਲਬਮਾਂ ਨੂੰ ਛਾਣੋ...",
"synced": "ਸਿੰਕ ਕੀਤਾ ਗਿਆ",
"plain": "ਸਾਦਾ",
"shuffle": "ਸ਼ਫਲ",
"search_tracks": "ਟ੍ਰੈਕ ਖੋਜੋ...",
"released": "ਜਾਰੀ ਹੋਇਆ",
"error": "ਤਰੁੱਟੀ {error}",
"title": "ਸਿਰਲੇਖ",
"time": "ਸਮਾਂ",
"more_actions": "ਹੋਰ ਕਾਰਵਾਈ",
"download_count": "ਡਾਊਨਲੋਡ ({count})",
"add_count_to_playlist": "({count}) ਨੂੰ ਪਲੇਸੂਚੀ ਵਿੱਚ ਜੋੜੋ",
"add_count_to_queue": "({count}) ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜੋ",
"play_count_next": "({count}) ਅਗਲਾ ਚਲਾਉ",
"album": "ਐਲਬਮ",
"copied_to_clipboard": "{data} ਕਲਿੱਪਬੋਰਡ ਉੱਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ",
"add_to_following_playlists": "{track} ਨੂੰ ਹੇਠ ਲਿਖੀ ਪਲੇਸੂਚੀ ਵਿੱਚ ਜੋੜੋ",
"add": "ਜੋੜੋ",
"added_track_to_queue": "{track} ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ ਗਿਆ",
"add_to_queue": "ਕਤਾਰ ਵਿੱਚ ਜੋੜੋ",
"track_will_play_next": "{track} ਅਗਲਾ ਚਲਾਉ",
"play_next": "ਅਗਲਾ ਚਲਾਉ",
"removed_track_from_queue": "{track} ਨੂੰ ਕਤਾਰ ਵਿੱਚੋਂ ਹਟਾਇਆ ਗਿਆ",
"remove_from_queue": "ਕਤਾਰ ਵਿੱਚ ਹਟਾਉ",
"remove_from_favorites": "ਪਸੰਦੀਦਾ ਵਿੱਚੋਂ ਹਟਾਉ",
"save_as_favorite": "ਪਸੰਦੀਦਾ ਵਜੋਂ ਸੰਭਾਲੋ",
"add_to_playlist": "ਪਲੇਸੂਚੀ ਵਿੱਚ ਜੋੜੋ",
"remove_from_playlist": "ਪਲੇਸੂਚੀ ਵਿੱਚੋਂ ਹਟਾਉ",
"add_to_blacklist": "ਕਾਲੀਸੂਚੀ ਵਿੱਚ ਜੋੜੋ",
"remove_from_blacklist": "ਕਾਲੀਸੂਚੀ ਵਿੱਚੋਂ ਹਟਾਉ",
"share": "ਸਾਂਝਾ ਕਰੋ",
"mini_player": "ਮਿੰਨੀ ਪਲੇਅਰ",
"slide_to_seek": "ਅੱਗੇ ਜਾਂ ਪਿੱਛੇ ਖੋਜਣ ਲਈ ਸਲਾਈਡ ਕਰੋ",
"shuffle_playlist": "ਪਲੇਸੂਚੀ ਸ਼ਫਲ ਕਰੋ",
"unshuffle_playlist": "ਪਲੇਸੂਚੀ ਅਣਸ਼ਫਲ ਕਰੋ",
"previous_track": "ਪਿਛਲਾ ਟ੍ਰੈਕ",
"next_track": "ਅਗਲਾ ਟ੍ਰੈਕ",
"pause_playback": "ਪਲੇਬੈਕ ਰੋਕੋ",
"resume_playback": "ਪਲੇਬੈਕ ਚਲਾਉ",
"loop_track": "ਲੂਪ ਟ੍ਰੈਕ",
"repeat_playlist": "ਪਲੇਸੂਚੀ ਦੁਹਰਾਉ",
"queue": "ਕਤਾਰ",
"alternative_track_sources": "ਵਿਕਲਪਕ ਟ੍ਰੈਕ ਸਰੋਤ",
"download_track": "ਟ੍ਰੈਕ ਡਾਊਨਲੋਡ ਕਰੋ",
"tracks_in_queue": "{tracks} ਟ੍ਰੈਕ ਕਤਾਰ ਵਿੱਚ ਹਨ",
"clear_all": "ਸਾਰੇ ਹਟਾਉ",
"show_hide_ui_on_hover": "ਹਵਰ ਉੱਤੇ ਯੂਆਈ ਦਿਖਾਉ/ਛਿਪਾਉ",
"always_on_top": "ਹਮੇਸ਼ਾ ਉੱਤੇ ਹੋਵੇ",
"exit_mini_player": "ਮਿੰਨੀ ਪਲੇਅਰ ਤੋਂ ਬਾਹਰ ਨਿਕਲੋ",
"download_location": "ਡਾਊਨਲੋਡ ਸਥਾਨ",
"account": "ਖ਼ਾਤਾ",
"login_with_spotify": "ਆਪਣੇ ਸਪੋਟਫਾਈ ਖ਼ਾਤੇ ਨਾਲ ਦਾਖ਼ਲ ਹੋਵੋ",
"connect_with_spotify": "ਸਪੋਟਫਾਈ ਨਾਲ ਕਨੈਕਟ ਕਰੋ",
"logout": "ਵਿਦਾਈ ਲਵੋ",
"logout_of_this_account": "ਇਸ ਖ਼ਾਤੇ ਤੋਂ ਵਿਦਾਈ ਲਵੋ",
"language_region": "ਭਾਸ਼ਾ ਅਤੇ ਖੇਤਰ",
"language": "ਭਾਸ਼ਾ",
"system_default": "ਸਿਸਟਮ ਡਿਫਾਲਟ",
"market_place_region": "ਮਾਰਕਿਟਪਲੇਸ ਖੇਤਰ",
"recommendation_country": "ਸੁਝਾਇਆ ਗਿਆ ਦੇਸ਼",
"appearance": "ਦਿੱਖ",
"layout_mode": "ਲੇਆਊਟ ਮੋਡ",
"override_layout_settings": "ਓਵਰਰਾਈਡ ਰਿਸਪਾਂਸਿਵ ਲੇਆਊਟ ਮੋਡ ਸੈਟਿੰਗ",
"adaptive": "ਅਨੁਕੂਲ",
"compact": "ਕੰਪੈਕਟ",
"extended": "ਵਿਸਤ੍ਰਿਤ",
"theme": "ਥੀਮ",
"dark": "ਗੂੜ੍ਹਾ",
"light": "ਚਾਨਣ",
"system": "ਸਿਸਟਮ",
"accent_color": "ਅੱਖਰਸ਼ੈਲੀ ਦਾ ਰੰਗ",
"sync_album_color": "ਐਲਬਮ ਦਾ ਰੰਗ ਸਿੰਕ ਕਰੋ",
"sync_album_color_description": "ਐਲਬਮ ਕਲਾ ਦਾ ਪ੍ਰਾਇਮਰੀ ਰੰਗ ਇੱਕ ਲਹਿਜ਼ੇ ਦੇ ਰੰਗ ਵਜੋਂ ਵਰਤਿਆ ਜਾਂਦਾ ਹੈ",
"playback": "ਪਲੇਬੈਕ",
"audio_quality": "ਆਡੀਉ ਗੁਣਵੱਤਾ",
"high": "ਉੱਚ",
"low": "ਨਿਮਨ",
"pre_download_play": "ਪਹਿਲਾਂ ਡਾਊਨਲੋਡ ਕਰੋ ਅਤੇ ਚਲਾਉ",
"pre_download_play_description": "ਸਟ੍ਰੀਮਿੰਗ ਆਡੀਓ ਦੀ ਬਜਾਏ ਬਾਈਟ ਡਾਊਨਲੋਡ ਕਰੋ ਅਤੇ ਚਲਾਓ (ਉੱਚ ਬੈਂਡਵਿਡਥ ਉਪਭੋਗਤਾਵਾਂ ਲਈ ਸਿਫ਼ਾਰਿਸ਼ ਕੀਤੀ ਗਈ ਹੈ)",
"skip_non_music": "ਗੀਤ ਦੇ ਇਲਾਵਾ ਸੈਗਮੈਂਟਾਂ ਨੂੰ ਛੱਡੋ (ਸਪਾਂਸਰਬਲਾਕ)",
"blacklist_description": "ਕਾਲੀਸੂਚੀ ਵਿੱਚ ਸ਼ਾਮਲ ਟ੍ਰੈਕ ਅਤੇ ਕਲਾਕਾਰ",
"wait_for_download_to_finish": "ਵਰਤਮਾਨ ਡਾਊਨਲੋਡ ਸਮਾਪਤ ਹੋਣ ਤੱਕ ਇੰਤਜ਼ਾਰ ਕਰੋ",
"desktop": "ਡੈਸਕਟਾਪ",
"close_behavior": "ਬੰਦ ਕਰਨ ਦਾ ਵਿਵਹਾਰ",
"close": "ਬੰਦ ਕਰੋ",
"minimize_to_tray": "ਟ੍ਰੇ ਵਿੱਚ ਘੱਟ ਕਰੋ",
"show_tray_icon": "ਸਿਸਟਮ ਟ੍ਰੇ ਆਈਕਨ ਦਿਖਾਉ",
"about": "ਬਾਬਤ",
"u_love_spotube": "ਅਸੀਂ ਜਾਣਦੇ ਹਾਂ ਕਿ ਤੁਸੀਂ Spotube ਨੂੰ ਪਿਆਰ ਕਰਦੇ ਹੋ",
"check_for_updates": "ਅੱਪਡੇਟ ਲਈ ਜਾਂਚ ਕਰੋ",
"about_spotube": "Spotube ਦੇ ਬਾਰੇ",
"blacklist": "ਕਾਲੀਸੂਚੀ",
"please_sponsor": "ਕਿਰਪਾ ਕਰਕੇ ਸਪਾਂਸਰ / ਦਾਨ ਕਰੋ",
"spotube_description": "Spotube, ਇੱਕ ਹਲਕਾ, ਸਾਰੇ ਪਲੇਟਫਾਰਮਾਂ ਤੇ ਚੱਲਣ ਵਾਲਾ, ਮੁਫ਼ਤ ਸਪੋਟੀਫਾਈ ਕਲਾਇੰਟ",
"version": "ਸੰਸਕਰਨ",
"build_number": "ਬਿਲਡ ਨੰਬਰ",
"founder": "ਸੰਸਥਾਪਕ",
"repository": "ਭੰਡਾਰ",
"bug_issues": "ਦਿੱਕਤਾਂ+ਮੁੱਦੇ",
"made_with": "ਬੰਗਲਾਦੇਸ਼🇧🇩 ਵਿੱਚ ਦਿਲ ਨਾਲ ਬਣਾਇਆ ਗਿਆ",
"kingkor_roy_tirtho": "ਕਿੰਗਕੋਰ ਰੌਏ ਤਿਰਥੋ",
"copyright": "© 2021-{current_year} ਕਿੰਗਕੋਰ ਰੌਏ ਤਿਰਥੋ",
"license": "ਲਸੰਸ",
"add_spotify_credentials": "ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਤੁਹਾਡੇ ਸਪੋਟੀਫਾਈ ਕਰੇਡੈਂਸ਼ੀਅਲ ਜੋੜੋ",
"credentials_will_not_be_shared_disclaimer": "ਚਿੰਤਾ ਨਾ ਕਰੋ, ਤੁਹਾਡੇ ਕਰੇਡੈਂਸ਼ੀਅਲ ਕਿਸੇ ਵੀ ਤਰ੍ਹਾਂ ਨਾਲ ਇਕੱਠੇ ਜਾਂ ਸਾਂਝੇ ਨਹੀਂ ਕੀਤੇ ਜਾਣਗੇ",
"know_how_to_login": "ਇਸਨੂੰ ਕਿਵੇਂ ਕਰਨਾ ਹੈ, ਨਹੀਂ ਪਤਾ?",
"follow_step_by_step_guide": "ਕਦਮ ਨਾਲ ਕਦਮ ਗਾਈਡ ਦੇ ਨਾਲ ਚੱਲੋ",
"spotify_cookie": "ਸਪੋਟੀਫਾਈ {name} ਕੁਕੀ",
"cookie_name_cookie": "{name} ਕੁਕੀ",
"fill_in_all_fields": "ਕ੍ਰਿਪਾ ਸਾਰੇ ਫੀਲਡ ਭਰੋ",
"submit": "ਸਪੁਰਦ",
"exit": "ਬਾਹਰ ਨਿਕਲੋ",
"previous": "ਪਿਛਲਾ",
"next": "ਅਗਲਾ",
"done": "ਹੋ ਗਿਆ",
"step_1": "ਚਰਣ 1",
"first_go_to": "ਪਹਿਲਾਂ, ਜਾਉ",
"login_if_not_logged_in": "ਅਤੇ ਜੇਕਰ ਤੁਸੀਂ ਦਾਖ਼ਲ ਨਹੀਂ ਹੋਏ ਹੋ ਤਾਂ ਦਾਖ਼ਲ ਹੋਵੋ / ਸਾਈਨਅੱਪ ਕਰੋ",
"step_2": "ਚਰਣ 2",
"step_2_steps": "1. ਇੱਕ ਵਾਰ ਜਦੋਂ ਤੁਸੀਂ ਦਾਖ਼ਲ ਹੋ ਜਾਂਦੇ ਹੋ, ਤਾਂ F12 ਦਬਾਓ ਜਾਂ ਮਾਊਸ ਸੱਜਾ ਕਲਿੱਕ ਕਰੋ > ਜਾਂਚ ਕਰੋ ਤਾਂ ਕਿ ਬ੍ਰਾਊਜ਼ਰ DevTools ਖੁੱਲ੍ਹ ਜਾਵੇ।\n2. ਫਿਰ ਬ੍ਰਾਊਜ਼ਰ ਦੇ \"ਐਪਲੀਕੇਸ਼ਨ\" ਟੈਬ (Chrome, Edge, Brave ਆਦਿ) ਜਾਂ \"ਸਟੋਰੇਜ\" ਟੈਬ (Firefox, Palemoon ਆਦਿ) ਵਿੱਚ ਜਾਉ\n3. \"ਕੁਕੀਜ\" ਅਨੁਭਾਗ ਵਿੱਚ ਜਾਉ ਫਿਰ \"https: //accounts.spotify.com\" ਉਪ-ਅਨੁਭਾਗ ਵਿੱਚ ਜਾਉ",
"step_3": "ਚਰਣ 3",
"success_emoji": "ਸਫ਼ਲ🥳",
"success_message": "ਤੁਸੀਂ ਹੁਣ ਆਪਣੇ Spotify ਖ਼ਾਤੇ ਨਾਲ ਸਫ਼ਲਤਾਪੂਰਵਕ ਦਾਖ਼ਲ ਹੋ ਗਏ ਹੋ। ਤੁਸੀਂ ਵਧੀਆ ਕੰਮ ਕੀਤਾ!",
"step_4": "ਚਰਣ 4",
"something_went_wrong": "ਕੁੱਝ ਗਲਤ ਹੋ ਗਿਆ ਹੈ",
"piped_instance": "ਪਾਈਪਡ ਸਰਵਰ",
"piped_description": "ਪਾਈਪ ਕੀਤੇ ਗਏ ਸਰਵਰ",
"piped_warning": "ਗਾਣਿਆਂ ਨਾਲ ਮੇਲ ਕਰਨ ਲਈ ਵਰਤੇ ਜਾਂਦੇ ਹਨ, ਹੋ ਸਕਦਾ ਹੈ ਕਿ ਉਹਨਾਂ ਵਿੱਚੋਂ ਕੁਝ ਦੇ ਨਾਲ ਸਹੀ ਢੰਗ ਨਾਲ ਕੰਮ ਨਾ ਕਰੇ ਇਸਲਈ ਆਪਣੇ ਜੋਖਮ 'ਤੇ ਵਰਤੋਂ",
"generate_playlist": "ਪਲੇਸੂਚੀ ਬਣਾਉ",
"track_exists": "ਟ੍ਰੈਕ {track} ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ",
"replace_downloaded_tracks": "ਸਾਰੇ ਡਾਊਨਲੋਡ ਕੀਤੇ ਗਏ ਟ੍ਰੈਕ ਬਦਲੋ",
"skip_download_tracks": "ਸਾਰੇ ਡਾਊਨਲੋਡ ਕੀਤੇ ਗਏ ਟ੍ਰੈਕ ਛੱਡੋ",
"do_you_want_to_replace": "ਕੀ ਤੁਸੀਂ ਮੌਜੂਦਾ ਟ੍ਰੈਕ ਬਦਲਣਾ ਚਾਹੁੰਦੇ ਹੋ?",
"replace": "ਬਦਲੋ",
"skip": "ਛੱਡੋ",
"select_up_to_count_type": "{count} {type} ਤੱਕ ਚੁਣੋ",
"select_genres": "ਸ਼ੈਲੀਆਂ ਚੁਣੋ",
"add_genres": "ਸ਼ੈਲੀਆਂ ਜੋੜੋ",
"country": "ਦੇਸ਼",
"number_of_tracks_generate": "ਉਤਪੰਨ ਕਰਨ ਲਈ ਟ੍ਰੈਕਾਂ ਦੀ ਸੰਖਿਆ",
"acousticness": "ਧੁਨਿਕਤਾ",
"danceability": "ਨੱਚਣ ਵਾਲੇ",
"energy": "ਊਰਜਾ",
"instrumentalness": "ਅਲਾਪਿਕਤਾ",
"liveness": "ਜੀਵੰਤਤਾ",
"loudness": "ਸ਼ੋਰ",
"speechiness": "ਬੋਲਚਾਲਤਾ",
"valence": "ਮਨੋਦਯਤਾ",
"popularity": "ਲੋਕਪ੍ਰਿਯਤਾ",
"key": "ਕੁੰਜੀ",
"duration": "ਅਵਧੀ (ਸੈਕਿੰਡ)",
"tempo": "ਗਤੀ (BPM)",
"mode": "ਮੋਡ",
"time_signature": "ਸਮਾਂ ਛਾਪ",
"short": "ਸੰਖੇਪ",
"medium": "ਮੱਧਮ",
"long": "ਲੰਬਾ",
"min": "ਨਿਊਨਤਮ",
"max": "ਅਧਿਕਤਮ",
"target": "ਟੀਚਾ",
"moderate": "ਮੱਧਮ",
"deselect_all": "ਸਭ ਨੂੰ ਅਣਚੁਣਿਆ ਕਰੋ",
"select_all": "ਸਭ ਨੂੰ ਚੁਣੋ",
"are_you_sure": "ਕੁ ਤੁਹਾਨੂੰ ਯਕੀਨ ਹੈ?",
"generating_playlist": "ਤੁਹਾਡੀ ਆਪਣੀ ਪਲੇਸੂਚੀ ਬਣਾਈ ਜਾ ਰਹੀ ਹੈ...",
"selected_count_tracks": "{count} ਟ੍ਰੈਕ ਚੁਣੇ ਗਏ",
"download_warning": "ਜੇਕਰ ਤੁਸੀਂ ਸਾਰੇ ਟਰੈਕਾਂ ਨੂੰ ਬਲਕ ਵਿੱਚ ਡਾਊਨਲੋਡ ਕਰਦੇ ਹੋ, ਤਾਂ ਤੁਸੀਂ ਸਪਸ਼ਟ ਤੌਰ 'ਤੇ ਸੰਗੀਤ ਦੀ ਗੈਰ-ਕਾਨੂੰਨੀ ਨਕਲ ਕਰ ਰਹੇ ਹੋ ਅਤੇ ਸੰਗੀਤ ਦੇ ਰਚਨਾਤਮਕ ਸਮਾਜ ਨੂੰ ਨੁਕਸਾਨ ਪਹੁੰਚਾ ਰਹੇ ਹੋ। ਮੈਨੂੰ ਉਮੀਦ ਹੈ ਕਿ ਤੁਸੀਂ ਇਸ ਬਾਰੇ ਜਾਣਦੇ ਹੋ। ਹਮੇਸ਼ਾ ਕਲਾਕਾਰ ਦੀ ਮਿਹਨਤ ਦਾ ਸਤਿਕਾਰ ਅਤੇ ਸਮਰਥਨ ਕਰਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਕਰੋ।",
"download_ip_ban_warning": "ਬਾਹਰੀ ਡਾਉਨਲੋਡ ਬੇਨਤੀਆਂ ਕਾਰਨ ਤੁਹਾਡਾ IP YouTube 'ਤੇ ਵੱਧ ਤੋਂ ਵੱਧ ਬਲੌਕ ਹੋ ਸਕਦਾ ਹੈ। IP ਬਲਾਕ ਦਾ ਮਤਲਬ ਹੈ ਕਿ ਤੁਸੀਂ ਘੱਟੋ-ਘੱਟ 2-3 ਮਹੀਨਿਆਂ ਲਈ ਉਸੇ IP ਡਿਵਾਈਸ ਤੋਂ YouTube ਤੱਕ ਪਹੁੰਚ ਨਹੀਂ ਕਰ ਸਕੋਗੇ (ਭਾਵੇਂ ਤੁਸੀਂ ਲੌਗਇਨ ਕੀਤਾ ਹੋਵੇ)। ਅਤੇ ਜੇਕਰ ਅਜਿਹਾ ਹੁੰਦਾ ਹੈ ਤਾਂ Spottube ਕੋਈ ਜ਼ਿੰਮੇਵਾਰੀ ਨਹੀਂ ਲੈਂਦਾ।",
"by_clicking_accept_terms": "'ਸਵੀਕਾਰ' ਕਲਿੱਕ ਕਰਕੇ ਤੁਸੀਂ ਹੇਠਾਂ ਦਿੱਤੀਆਂ ਸ਼ਰਤਾਂ ਨਾਲ ਸਹਿਮਤ ਹੁੰਦੇ ਹੋ:",
"download_agreement_1": "ਮੈਨੂੰ ਪਤਾ ਹੈ ਕਿ ਮੈਂ ਗ਼ੈਰ-ਕਾਨੂੰਨੀ ਸੰਗੀਤ ਨੂੰ ਨਕਲੀ ਬਣਾ ਰਿਹਾ ਹਾਂ। ਮੈਂ ਬੁਰਾ ਹਾਂ",
"download_agreement_2": "ਮੈਂ ਜਿੱਥੇ ਵੀ ਹੋ ਸਕੇ ਕਲਾਕਾਰਾਂ ਦਾ ਸਮਰਥਨ ਕਰਾਂਗਾ ਅਤੇ ਮੈਂ ਅਜਿਹਾ ਇਸ ਲਈ ਕਰ ਰਿਹਾ ਹਾਂ ਕਿਉਂਕਿ ਮੇਰੇ ਕੋਲ ਉਨ੍ਹਾਂ ਦੀ ਕਲਾ ਖਰੀਦਣ ਲਈ ਪੈਸੇ ਨਹੀਂ ਹਨ।",
"download_agreement_3": "ਮੈਨੂੰ ਪੂਰੀ ਤਰ੍ਹਾਂ ਪਤਾ ਹੈ ਕਿ ਮੇਰਾ ਆਈਪੀ ਯੂਟਿਊਬ 'ਤੇ ਬਲੌਕ ਕੀਤਾ ਜਾ ਸਕਦਾ ਹੈ ਅਤੇ ਮੈਂ ਕਿਸੇ ਵੀ ਦੁਰਘਟਨਾ ਲਈ ਸਪੋਟੀਫਾਈ ਜਾਂ ਇਸਦੇ ਮਾਲਕਾਂ/ਸਬੰਧੀਆਂ ਨੂੰ ਜ਼ਿੰਮੇਵਾਰ ਨਹੀਂ ਠਹਿਰਾਉਂਦਾ ਹਾਂ।",
"decline": "ਇਨਕਾਰ ਕਰੋ",
"accept": "ਸਵੀਕਾਰ ਕਰੋ",
"details": "ਵਿਵਰਣ",
"youtube": "ਯੂਟਿਊਬ",
"channel": "ਚੈਨਲ",
"likes": "ਪਸੰਦ",
"dislikes": "ਨਾਪਸੰਦ",
"views": "ਵਿਯੂ",
"streamUrl": "ਸਟ੍ਰੀਮ ਯੂਆਰਐਲ",
"stop": "ਰੋਕੋ",
"sort_newest": "ਨਵੇਂ ਜੋੜੇ ਗਏ ਅਨੁਸਾਰ ਕ੍ਰਮਬੱਧ ਕਰੋ",
"sort_oldest": "ਸਭਤੋਂ ਪੁਰਾਣੇ ਅਨੁਸਾਰ ਕ੍ਰਮਬੱਧ ਕਰੋ",
"sleep_timer": "ਸਲੀਪ ਟਾਈਮਰ",
"mins": "{minutes} ਮਿੰਟ",
"hours": "{hours} ਘੰਟੇ",
"hour": "{hours} ਘੰਟਾ",
"custom_hours": "ਕਸਟਮ ਘੰਟੇ",
"logs": "ਚਿੱਠੇ",
"developers": "ਡਿਵੈਲਪਰ",
"not_logged_in": "ਤੁਸੀਂ ਦਾਖ਼ਲ ਨਹੀਂ ਹੋ",
"search_mode": "ਖੋਜ ਮੋਡ",
"audio_source": "ਆਡੀਓ ਸਰੋਤ",
"ok": "ਠੀਕ ਹੈ",
"failed_to_encrypt": "ਇਨਕ੍ਰਿਪਟ ਕਰਨ ਵਿੱਚ ਅਸਫਲ",
"encryption_failed_warning": "Spottube ਤੁਹਾਡੇ ਡੇਟਾ ਨੂੰ ਸੁਰੱਖਿਅਤ ਢੰਗ ਨਾਲ ਸਟੋਰ ਕਰਨ ਲਈ ਐਨਕ੍ਰਿਪਸ਼ਨ ਦੀ ਵਰਤੋਂ ਕਰਦਾ ਹੈ। ਪਰ ਇਹ ਅਸਫਲ ਰਿਹਾ. ਇਸ ਲਈ, ਇਹ ਅਸੁਰੱਖਿਅਤ ਸਟੋਰੇਜ 'ਤੇ ਵਾਪਸ ਆ ਜਾਵੇਗਾ\nਜੇਕਰ ਤੁਸੀਂ ਲੀਨਕਸ ਦੀ ਵਰਤੋਂ ਕਰ ਰਹੇ ਹੋ, ਤਾਂ ਕਿਰਪਾ ਕਰਕੇ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਹਾਡੇ ਕੋਲ ਕੋਈ ਗੁਪਤ-ਸੇਵਾਵਾਂ ਜਿਵੇਂ ਕਿ gnome-keyring, kde-wallet, Keepassxc ਆਦਿ ਇੰਸਟਾਲ ਹਨ।",
"querying_info": "ਜਾਣਕਾਰੀ ਪ੍ਰਾਪਤ ਕਰ ਰਿਹਾ ਹੈ",
"piped_api_down": "ਪਾਈਪਡ ਏਪੀਆਈ ਡਾਊਨ ਹੈ",
"piped_down_error_instructions": "ਪਾਈਪਡ ਇੰਸਟੈਂਸ {pipedInstance} ਵਰਤਮਾਨ ਵਿੱਚ ਡਾਊਨ ਹੈ\n\nਇੰਸਟੈਂਸ ਬਦਲੋ ਜਾਂ 'ਏਪੀਆਈ ਪ੍ਰਕਾਰ' ਨੂੰ ਅਧਿਕਾਰਤ YouTube API ਵਿੱਚ ਬਦਲੋ\n\nਤਬਦੀਲੀਆਂ ਤੋਂ ਬਾਅਦ ਐਪ ਨੂੰ ਦੁਬਾਰਾ ਚਲਾਉਣਾ ਯਕੀਨੀ ਬਣਾਓ",
"you_are_offline": "ਤੁਸੀਂ ਵਰਤਮਾਨ ਵਿੱਚ ਆਫ਼ਲਾਈਨ ਹੋ",
"connection_restored": "ਤੁਹਾਡਾ ਇੰਟਰਨੈੱਟ ਕੁਨੈਕਸ਼ਨ ਬਹਾਲ ਹੋ ਗਿਆ ਹੈ",
"use_system_title_bar": "ਸਿਸਟਮ ਸਿਰਲੇਖ ਪੱਟੀ ਵਰਤੋ",
"update_playlist": "ਪਲੇਸੂਚੀ ਅੱਪਡੇਟ ਕਰੋ",
"update": "ਅੱਪਡੇਟ ਕਰੋ",
"crunching_results": "ਨਤੀਜਿਆਂ 'ਤੇ ਕਾਰਵਾਈ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ...",
"search_to_get_results": "ਨਤੀਜੇ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ ਖੋਜੋ",
"use_amoled_mode": "AMOLED ਮੋਡ ਵਰਤੋ",
"pitch_dark_theme": "ਪਿੱਚ ਬਲੈਕ ਗੂੜ੍ਹੀ ਥੀਮ",
"normalize_audio": "ਆਡੀਓ ਨੂੰ ਸਧਾਰਨ ਕਰੋ",
"change_cover": "ਕਵਰ ਬਦਲੋ",
"add_cover": "ਕਵਰ ਜੋੜੋ",
"restore_defaults": "ਡਿਫਾਲਟ ਸੈਟਿੰਗਾਂ ਬਹਾਲ ਕਰੋ",
"download_music_codec": "ਸੰਗੀਤ ਕੋਡੈਕ ਡਾਊਨਲੋਡ ਕਰੋ",
"streaming_music_codec": "ਸਟ੍ਰੀਮਿੰਗ ਸੰਗੀਤ ਕੋਡੈਕ",
"login_with_lastfm": "Last.fm ਨਾਲ ਦਾਖ਼ਲ ਹੋਵੋ",
"connect": "ਕਨੈਕਟ ਕਰੋ",
"disconnect_lastfm": "Last.fm ਤੋਂ ਡਿਸਕਨੈਕਟ ਕਰੋ",
"disconnect": "ਡਿਸਕਨੈਕਟ ਕਰੋ",
"username": "ਉਪਯੋਗਕਰਤਾ ਨਾਮ",
"password": "ਪਾਸਵਰਡ",
"login": "ਦਾਖ਼ਲ ਹੋਵੋ",
"login_with_your_lastfm": "ਆਪਣੇ Last.fm ਖ਼ਾਤੇ ਨਾਲ਼ ਦਾਖ਼ਲ ਹੋਵੋ",
"scrobble_to_lastfm": "Last.fm ਉੱਤੇ ਸਕਰੌਬਲ ਕਰੋ",
"go_to_album": "ਐਲਬਮ ਤੇ ਜਾਓ",
"discord_rich_presence": "ਡਿਸਕਾਰਡ ਰਿਚ ਪ੍ਰੈਜੈਂਸ",
"browse_all": "ਸਾਰਿਆਂ ਨੂੰ ਬ੍ਰਾਊਜ਼ ਕਰੋ",
"genres": "ਸ਼ੈਲੀਆਂ",
"explore_genres": "ਸ਼ੈਲੀਆਂ ਫਰੋਲੋ",
"step_3_steps": "\"sp_dc\" ਕੁਕੀ ਦਾ ਮੁੱਲ ਕਾਪੀ ਕਰੋ",
"step_4_steps": "ਕਾਪੀ ਕੀਤੇ ਗਏ \"sp_dc\" ਮੁੱਲ ਨੂੰ ਪੈਸਟ ਕਰੋ",
"friends": "ਦੋਸਤ",
"no_lyrics_available": "ਮਾਫ਼ ਕਰਨਾ, ਇਸ ਟ੍ਰੈਕ ਦੇ ਬੋਲ ਨਹੀਂ ਮਿਲ ਸਕੋ",
"sort_duration": "ਅਵਧੀ ਅਨੁਸਾਰ ਕ੍ਰਮਬੱਧ ਕਰੋ",
"start_a_radio": "ਰੇਡੀਉ ਸ਼ੁਰੂ ਕਰੋ",
"how_to_start_radio": "ਰੇਡੀਉ ਕਿਵੇਂ ਸ਼ੁਰੂ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
"replace_queue_question": "ਕੀ ਤੁਸੀਂ ਮੌਜੂਦਾ ਕਤਾਰ ਨੂੰ ਬਦਲਣਾ ਚਾਹੁੰਦੇ ਹੋ ਜਾਂ ਜੋੜਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
"endless_playback": "ਬੇਅੰਤ ਪਲੇਬੈਕ",
"delete_playlist": "ਪਲੇਸੂਚੀ ਹਟਾਉ",
"delete_playlist_confirmation": "ਕੀ ਤੁਸੀਂ ਸੱਚਮੁੱਚ ਇਸ ਪਲੇਸੂਚੀ ਨੂੰ ਹਟਾਉਣਾ ਚਾਹੁੰਦੇ ਹੋ?",
"local_tracks": "ਸਥਾਨਕ ਟ੍ਰੈਕ",
"song_link": "ਗੀਤ ਦਾ ਲਿੰਕ",
"skip_this_nonsense": "ਇਸਨੂੰ ਛੱਡੋ",
"freedom_of_music": "“ਸੰਗੀਤ ਦੀ ਅਜ਼ਾਦੀ”",
"freedom_of_music_palm": "“ਹੱਥ ਵਿੱਚ ਸੰਗੀਤ ਦੀ ਅਜ਼ਾਦੀ”",
"get_started": "ਆਉ ਸ਼ੁਰੂ ਕਰੋ",
"youtube_source_description": "ਸਿਫ਼ਾਰਸ਼ ਕੀਤਾ ਗਿਆ ਅਤੇ ਸਭਤੋਂ ਵਧੀਆ ਕੰਮ ਕਰਦਾ ਹੈ।",
"piped_source_description": "ਮੁਫ਼ਤ ਮਹਿਸੂਸ ਕਰ ਰਹੇ ਹੋ? YouTube ਦੇ ਸਮਾਨ ਪਰ ਕਾਫੀ ਮੁਫ਼ਤ।",
"jiosaavn_source_description": "ਦੱਖਣੀ ਏਸ਼ੀਆਈ ਖੇਤਰ ਲਈ ਸਭ ਤੋਂ ਵਧੀਆ।",
"highest_quality": "ਸਭਤੋਂ ਵਧੀਆ ਗੁਣਵੱਤਾ: {quality}",
"select_audio_source": "ਆਡੀਉ ਸਰੋਤ ਚੁਣੋ",
"endless_playback_description": "ਕ੍ਰਮਬੱਧ ਕਤਾਰ ਦੇ ਅੰਤ ਵਿੱਚ ਆਪਣੇ ਆਪ ਨਵੇਂ ਗੀਤ ਸ਼ਾਮਲ ਕਰੋ",
"choose_your_region": "ਆਪਣਾ ਖੇਤਰ ਚੁਣੋ",
"choose_your_region_description": "ਇਹ Spotube ਨੂੰ ਤੁਹਾਡੇ ਟਿਕਾਣੇ ਲਈ ਸਹੀ ਸਮੱਗਰੀ ਦਿਖਾਉਣ ਵਿੱਚ ਤੁਹਾਡੀ ਮਦਦ ਕਰੇਗਾ।",
"choose_your_language": "ਆਪਣੀ ਭਾਸ਼ਾ ਚੁਣੋ",
"help_project_grow": "ਇਸ ਪਰਿਯੋਜਨਾ ਨੂੰ ਵਧਾਉਣ ਵਿੱਚ ਮਦਦ ਕਰੋ",
"help_project_grow_description": "Spotube ਇੱਕ ਓਪਨ ਸੋਰਸ ਪ੍ਰੋਜੈਕਟ ਹੈ। ਤੁਸੀਂ ਯੋਗਦਾਨ ਦੇ ਕੇ, ਬੱਗਾਂ ਦੀ ਰਿਪੋਰਟ ਕਰਕੇ, ਜਾਂ ਨਵੀਆਂ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਦਾ ਸੁਝਾਅ ਦੇ ਕੇ ਇਸ ਪ੍ਰੋਜੈਕਟ ਨੂੰ ਵਧਾ ਸਕਦੇ ਹੋ।",
"contribute_on_github": "GitHub ਉੱਤੇ ਯੋਗਦਾਨ ਦਿਉ",
"donate_on_open_collective": "ਓਪਨ ਕਲੈਕਟਿਵ ਉੱਤੇ ਯੋਗਦਾਨ ਦਿਉ",
"browse_anonymously": "ਬਿਨ੍ਹਾ ਨਾਮ ਦੇ ਬ੍ਰਾਊਜ਼ ਕਰੋ",
"enable_connect": "ਕਨੈਕਟ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ",
"enable_connect_description": "ਹੋਰ ਡਿਵਾਈਸਾਂ ਤੋਂ Spottube ਨੂੰ ਕੰਟਰੋਲ ਕਰੋ",
"devices": "ਉਪਕਰਣ",
"select": "ਚੁਣੋ",
"connect_client_alert": "ਤੁਹਾਨੂੰ {client} ਦੁਆਰਾ ਕੰਟਰੋਲ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ",
"this_device": "ਇਹ ਉਪਕਰਣ",
"remote": "ਰਿਮੋਟ",
"local_library": "ਸਥਾਨਕ ਲਾਇਬ੍ਰੇਰੀ",
"add_library_location": "ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ",
"remove_library_location": "ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚੋਂ ਹਟਾਉ",
"local_tab": "ਸਥਾਨਕ",
"stats": "ਅੰਕੜੇ",
"and_n_more": "और {count} और",
"recently_played": "ਹਾਲ ਹੀ ਵਿੱਚ ਚਲਾਏ ਗਏ",
"browse_more": "ਹੋਰ ਬ੍ਰਾਉਜ਼ ਕਰੋ",
"no_title": "ਕੋਈ ਸਿਰਲੇਖ ਨਹੀਂ",
"not_playing": "ਚੱਲ ਨਹੀਂ ਰਿਹਾ",
"epic_failure": "ਮਹਾਨ ਅਸਫ਼ਲਤਾ!",
"added_num_tracks_to_queue": "{tracks_length} ਟ੍ਰੈਕ ਕਤਾਰ ਵਿੱਚ ਜੋੜੇ ਗਏ",
"spotube_has_an_update": "Spotube ਲਈ ਇੱਕ ਅੱਪਡੇਟ ਹੈ",
"download_now": "ਹੁਣੇ ਡਾਊਨਲੋਡ ਕਰੋ",
"nightly_version": "Spotube Nightly {nightlyBuildNum} ਜਾਰੀ ਕੀਤਾ ਗਿਆ ਹੈ",
"release_version": "Spotube v{version} ਜਾਰੀ ਕੀਤਾ ਗਿਆ ਹੈ",
"read_the_latest": "ਨਵੀਨਤਮ ਪੜ੍ਹੋ",
"release_notes": "ਰਿਲੀਜ਼ ਨੋਟਸ",
"pick_color_scheme": "ਰੰਗ ਯੋਜਨਾ ਚੁਣੋ",
"save": "ਸੰਭਾਲੋ",
"choose_the_device": "ਉਪਕਰਣ ਚੁਣੋ:",
"multiple_device_connected": "ਕਈ ਉਪਕਰਣ ਜੋੜੇ ਗਏ ਹਨ।\nਉਸ ਉਪਕਰਣ ਨੂੰ ਚੁਣੋ ਜਿਸ ਤੇ ਤੁਸੀਂ ਕਿਰਿਆ ਕਰਨੀ ਚਾਹੁੰਦੇ ਹੋ",
"nothing_found": "ਕੁੱਝ ਨਹੀਂ ਮਿਲਿਆ",
"the_box_is_empty": "ਡੱਬਾ ਖਾਲੀ ਹੈ",
"top_artists": "ਚੋਟੀ ਦਾ ਕਲਾਕਾਰ",
"top_albums": "ਚੋਟੀ ਦੀ ਐਲਬਮ",
"this_week": "ਇਸ ਹਫ਼ਤੇ",
"this_month": "ਇਸ ਮਹੀਨੇ",
"last_6_months": "ਪਿਛਲੇ 6 ਮਹੀਨੇ",
"this_year": "ਇਸ ਸਾਲ",
"last_2_years": "ਪਿਛਲੇ 2 साल",
"all_time": "ਪੂਰਾ ਸਮਾਂ",
"powered_by_provider": "{providerName} ਦੁਆਰਾ ਸੰਚਾਲਿਤ",
"email": "ਈਮੇਲ",
"profile_followers": "ਫ਼ਾਲੋਅਰ",
"birthday": "ਜਨਮਦਿਨ",
"subscription": "ਗਾਹਕੀਆਂ",
"not_born": "ਹਲੇ ਪੈਦਾ ਨਹੀਂ ਹੋਇਆ",
"hacker": "ਹੈਕਰ",
"profile": "ਪ੍ਰੋਫ਼ਾਈਲ",
"no_name": "ਕੋਈ ਨਾਮ ਨਹੀਂ",
"edit": "ਸੋਧੋ",
"user_profile": "ਵਰਤੋਂਕਾਰ ਪ੍ਰੋਫ਼ਾਈਲ",
"count_plays": "{count} ਪਲੇ",
"streaming_fees_hypothetical": "ਸਟ੍ਰੀਮਿੰਗ ਫੀਸ (ਕਾਲਪਨਿਕ)",
"count_mins": "{minutes} ਮਿੰਟ",
"summary_minutes": "ਮਿੰਟ",
"summary_listened_to_music": "ਸੁਣਿਆ ਗਿਆ ਸੰਗੀਤ",
"summary_songs": "ਗੀਤ",
"summary_streamed_overall": "ਕੁੱਲ ਸਟ੍ਰੀਮ",
"summary_owed_to_artists": "ਕਲਾਕਾਰਾਂ ਨੂੰ ਦੇਣਦਾਰ\nਇਸ ਮਹੀਨੇ",
"summary_artists": "ਕਲਾਕਾਰ",
"summary_music_reached_you": "ਸੰਗੀਤ ਤੁਹਾਡੇ ਤੱਕ ਪਹੁੰਚ ਗਿਆ",
"summary_full_albums": "ਪੂਰਾ ਐਲਬਮ",
"summary_got_your_love": "ਤੁਹਾਡਾ ਪਿਆਰ ਮਿਲਿਆ",
"summary_playlists": "ਪਲੇਸੂਚੀਆਂ",
"summary_were_on_repeat": "ਦੁਹਰਾਇਆ ਗਿਆ",
"total_money": "ਕੁੱਲ {money}",
"minutes_listened": "ਮਿੰਟ ਸੁਣਿਆ",
"streamed_songs": "ਗੀਤ ਸਟ੍ਰੀਮ ਕੀਤੇ",
"count_streams": "{count} ਸਟ੍ਰੀਮ",
"owned_by_you": "ਤੁਹਾਡੀ ਮਲਕੀਅਤ",
"copied_shareurl_to_clipboard": "{shareUrl} ਕਲਿੱਪਬੋਰਡ ਉੱਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ",
"spotify_hipotetical_calculation": "*ਇਸਦੀ ਗਣਨਾ Spotify ਪ੍ਰਤੀ ਸਟ੍ਰੀਮ\n$0.003 ਤੋਂ $0.005 ਦੇ ਭੁਗਤਾਨ ਦੇ ਆਧਾਰ 'ਤੇ ਕੀਤੀ ਜਾਂਦੀ ਹੈ। ਇਹ ਇੱਕ ਕਾਲਪਨਿਕ\nਗਣਨਾ ਹੈ ਜੋ ਉਪਭੋਗਤਾ ਨੂੰ ਇਸ ਬਾਰੇ ਜਾਣਕਾਰੀ ਦਿੰਦਾ ਹੈ ਕਿ \nਜੇਕਰ ਉਹਨਾਂ ਨੇ Spotify 'ਤੇ ਗੀਤ ਸੁਣਿਆ ਤਾਂ ਉਹਨਾਂ ਨੇ ਕਿੰਨਾ ਭੁਗਤਾਨ ਕੀਤਾ ਹੋਵੇਗਾ।",
"webview_not_found": "ਵੈੱਬਵਿਯੂ ਨਹੀਂ ਮਿਲਿਆ",
"webview_not_found_description": "ਤੁਹਾਡੇ ਡਿਵਾਈਸ ਉੱਤੇ ਕੋਈ ਵੈੱਬਵਿਯੂ ਰਨਟਾਈਮ ਇੰਸਟਾਲ ਨਹੀਂ ਹੈ।\nਜੈਕਰ ਇਹ ਇੰਸਟਾਲ ਹੈ ਤਾਂ ਪੱਕਾ ਕਰੋ ਕਿ ਇਹ ਵਾਤਾਵਰਨ ਰਸਤੇ ਵਿੱਚ ਹੈ\n\nਇੰਸਟਾਲ ਕਰਨ ਤੋਂ ਬਾਅਦ, ਐਪ ਦੁਬਾਰਾ ਚਲਾਉ",
"unsupported_platform": "ਅਸਮਰਥਿਤ ਪਲੇਟਫਾਰਮ",
"invidious_instance": "ਇਨਡਿਵੀਡਿਊਸ ਸਰਵਰ ਇੰਸਟੈਂਸ",
"invidious_description": "ਟ੍ਰੈਕ ਮਿਲਾਨ ਲਈ ਇਨਡਿਵੀਡਿਊਸ ਸਰਵਰ ਇੰਸਟੈਂਸ",
"invidious_warning": "ਕੁੱਝ ਇੰਸਟੈਂਸ ਸਹੀ ਕੰਮ ਨਹੀਂ ਕਰ ਰਹੇ ਹਨ। ਆਪਣੇ ਜੋਖਮ ਤੇ ਵਰਤੋ",
"invidious_source_description": "ਪਾਈਪਡ ਦੇ ਸਮਾਨ, ਪਰ ਵੱਧ ਉਪਲਬਧਤਾ ਨਾਲ",
"cache_music": "ਸੰਗੀਤ ਨੂੰ ਕੈਸ਼ ਕਰੋ",
"open": "ਖੋਲ੍ਹੋ",
"cache_folder": "ਕੈਸ਼ ਫੋਲਡਰ",
"export": "ਨਿਰਯਾਤ ਕਰੋ",
"clear_cache": "ਕੈਸ਼ ਸਾਫ਼ ਕਰੋ",
"clear_cache_confirmation": "ਕੀ ਤੁਸੀਂ ਕੈਸ਼ ਸਾਫ਼ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
"export_cache_files": "ਕੈਸ਼ ਫ਼ਾਈਲਾਂ ਨਿਰਯਾਤ ਕਰੋ",
"found_n_files": "{count} ਫ਼ਾਈਲਾਂ ਮਿਲੀਆਂ",
"export_cache_confirmation": "ਕੀ ਤੁਸੀਂ ਇਹਨਾਂ ਫ਼ਾਈਲਾਂ ਨੂੰ ਨਿਰਯਾਤ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ",
"exported_n_out_of_m_files": "{files} ਵਿੱਚੋਂ {filesExported} ਫ਼ਾਈਲਾਂ ਨਿਰਯਾਤ ਕੀਤੀਆਂ ਗਈਆਂ"
}